From 4699739814cc7e57a0eef71990bd1ef502cc33c8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 19 Jul 2025 15:00:11 -0400 Subject: [PATCH] shitty hack for terrible charm bubbletea performance --- bun.lock | 4 +- packages/tui/go.mod | 1 + packages/tui/go.sum | 2 + .../tui/internal/components/chat/messages.go | 24 +- .../tui/internal/components/dialog/help.go | 2 +- .../components/fileviewer/fileviewer.go | 2 +- packages/tui/internal/tui/tui.go | 7 + packages/tui/internal/util/util.go | 4 +- packages/tui/internal/viewport/highlight.go | 141 ++++ packages/tui/internal/viewport/keymap.go | 56 ++ packages/tui/internal/viewport/viewport.go | 769 ++++++++++++++++++ 11 files changed, 999 insertions(+), 13 deletions(-) create mode 100644 packages/tui/internal/viewport/highlight.go create mode 100644 packages/tui/internal/viewport/keymap.go create mode 100644 packages/tui/internal/viewport/viewport.go diff --git a/bun.lock b/bun.lock index f789eb74..7d1d9610 100644 --- a/bun.lock +++ b/bun.lock @@ -493,7 +493,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.20.7", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="], - "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], @@ -627,7 +627,7 @@ "buffer": ["buffer@4.9.2", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="], - "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/packages/tui/go.mod b/packages/tui/go.mod index e23e05c3..0b469838 100644 --- a/packages/tui/go.mod +++ b/packages/tui/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/BurntSushi/toml v1.5.0 github.com/alecthomas/chroma/v2 v2.18.0 + github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 github.com/charmbracelet/glamour v0.10.0 diff --git a/packages/tui/go.sum b/packages/tui/go.sum index 370ea712..f41abaf4 100644 --- a/packages/tui/go.sum +++ b/packages/tui/go.sum @@ -20,6 +20,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE= github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno= diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 11a3bcd9..9b6920ad 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -2,9 +2,9 @@ package chat import ( "fmt" + "log/slog" "strings" - "github.com/charmbracelet/bubbles/v2/viewport" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/sst/opencode-sdk-go" @@ -15,6 +15,7 @@ import ( "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" + "github.com/sst/opencode/internal/viewport" ) type MessagesComponent interface { @@ -99,8 +100,8 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.lineCount = msg.lineCount m.rendering = false m.loading = false - m.viewport.SetHeight(m.height - lipgloss.Height(m.header)) - m.viewport.SetContent(msg.content) + m.tail = m.viewport.AtBottom() + m.viewport = msg.viewport if m.tail { m.viewport.GotoBottom() } @@ -109,16 +110,16 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + m.tail = m.viewport.AtBottom() viewport, cmd := m.viewport.Update(msg) m.viewport = viewport - m.tail = m.viewport.AtBottom() cmds = append(cmds, cmd) return m, tea.Batch(cmds...) } type renderCompleteMsg struct { - content string + viewport viewport.Model partCount int lineCount int } @@ -127,6 +128,7 @@ func (m *messagesComponent) renderView() tea.Cmd { m.header = m.renderHeader() if m.rendering { + slog.Debug("pending render, skipping") m.dirty = true return func() tea.Msg { return nil @@ -135,6 +137,8 @@ func (m *messagesComponent) renderView() tea.Cmd { m.dirty = false m.rendering = true + viewport := m.viewport + return func() tea.Msg { measure := util.Measure("messages.renderView") defer measure() @@ -396,8 +400,11 @@ func (m *messagesComponent) renderView() tea.Cmd { } content := "\n" + strings.Join(blocks, "\n\n") + viewport.SetHeight(m.height - lipgloss.Height(m.header)) + viewport.SetContent(content) + return renderCompleteMsg{ - content: content, + viewport: viewport, partCount: partCount, lineCount: lineCount, } @@ -562,9 +569,12 @@ func (m *messagesComponent) View() string { ) } + measure := util.Measure("messages.View") + viewport := m.viewport.View() + measure() return styles.NewStyle(). Background(t.Background()). - Render(m.header + "\n" + m.viewport.View()) + Render(m.header + "\n" + viewport) } func (m *messagesComponent) Reload() tea.Cmd { diff --git a/packages/tui/internal/components/dialog/help.go b/packages/tui/internal/components/dialog/help.go index 80123165..15931724 100644 --- a/packages/tui/internal/components/dialog/help.go +++ b/packages/tui/internal/components/dialog/help.go @@ -1,13 +1,13 @@ package dialog import ( - "github.com/charmbracelet/bubbles/v2/viewport" tea "github.com/charmbracelet/bubbletea/v2" "github.com/sst/opencode/internal/app" commandsComponent "github.com/sst/opencode/internal/components/commands" "github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/theme" + "github.com/sst/opencode/internal/viewport" ) type helpDialog struct { diff --git a/packages/tui/internal/components/fileviewer/fileviewer.go b/packages/tui/internal/components/fileviewer/fileviewer.go index 6627bc3f..3fa333f4 100644 --- a/packages/tui/internal/components/fileviewer/fileviewer.go +++ b/packages/tui/internal/components/fileviewer/fileviewer.go @@ -4,7 +4,6 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/v2/viewport" tea "github.com/charmbracelet/bubbletea/v2" "github.com/sst/opencode/internal/app" @@ -15,6 +14,7 @@ import ( "github.com/sst/opencode/internal/styles" "github.com/sst/opencode/internal/theme" "github.com/sst/opencode/internal/util" + "github.com/sst/opencode/internal/viewport" ) type DiffStyle int diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index a81c1a6b..d09f3d34 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -103,6 +103,9 @@ func (a appModel) Init() tea.Cmd { } func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + measure := util.Measure("Update") + defer measure("from", fmt.Sprintf("%T", msg)) + var cmd tea.Cmd var cmds []tea.Cmd @@ -529,11 +532,13 @@ func (a appModel) View() string { var mainLayout string + measure := util.Measure("app.View") if a.app.Session.ID == "" { mainLayout = a.home() } else { mainLayout = a.chat() } + measure() mainLayout = styles.NewStyle(). Background(t.Background()). Padding(0, 2). @@ -691,6 +696,8 @@ func (a appModel) home() string { } func (a appModel) chat() string { + measure := util.Measure("chat.View") + defer measure() effectiveWidth := a.width - 4 t := theme.CurrentTheme() editorView := a.editor.View() diff --git a/packages/tui/internal/util/util.go b/packages/tui/internal/util/util.go index c7898acb..fdefb290 100644 --- a/packages/tui/internal/util/util.go +++ b/packages/tui/internal/util/util.go @@ -40,8 +40,8 @@ func IsWsl() bool { func Measure(tag string) func(...any) { startTime := time.Now() - return func(tags ...any) { - args := append([]any{"timeTakenMs", time.Since(startTime).Milliseconds()}, tags...) + return func(args ...any) { + args = append(args, []any{"timeTakenMs", time.Since(startTime).Milliseconds()}...) slog.Debug(tag, args...) } } diff --git a/packages/tui/internal/viewport/highlight.go b/packages/tui/internal/viewport/highlight.go new file mode 100644 index 00000000..ec0ffda5 --- /dev/null +++ b/packages/tui/internal/viewport/highlight.go @@ -0,0 +1,141 @@ +package viewport + +import ( + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" + "github.com/rivo/uniseg" +) + +// parseMatches converts the given matches into highlight ranges. +// +// Assumptions: +// - matches are measured in bytes, e.g. what [regex.FindAllStringIndex] would return +// - matches were made against the given content +// - matches are in order +// - matches do not overlap +// - content is line terminated with \n only +// +// We'll then convert the ranges into [highlightInfo]s, which hold the starting +// line and the grapheme positions. +func parseMatches( + content string, + matches [][]int, +) []highlightInfo { + if len(matches) == 0 { + return nil + } + + line := 0 + graphemePos := 0 + previousLinesOffset := 0 + bytePos := 0 + + highlights := make([]highlightInfo, 0, len(matches)) + gr := uniseg.NewGraphemes(ansi.Strip(content)) + + for _, match := range matches { + byteStart, byteEnd := match[0], match[1] + + // hilight for this match: + hi := highlightInfo{ + lines: map[int][2]int{}, + } + + // find the beginning of this byte range, setup current line and + // grapheme position. + for byteStart > bytePos { + if !gr.Next() { + break + } + if content[bytePos] == '\n' { + previousLinesOffset = graphemePos + 1 + line++ + } + graphemePos += max(1, gr.Width()) + bytePos += len(gr.Str()) + } + + hi.lineStart = line + hi.lineEnd = line + + graphemeStart := graphemePos + + // loop until we find the end + for byteEnd > bytePos { + if !gr.Next() { + break + } + + // if it ends with a new line, add the range, increase line, and continue + if content[bytePos] == '\n' { + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemePos-previousLinesOffset+1, colstart) // +1 its \n itself + + if colend > colstart { + hi.lines[line] = [2]int{colstart, colend} + hi.lineEnd = line + } + + previousLinesOffset = graphemePos + 1 + line++ + } + + graphemePos += max(1, gr.Width()) + bytePos += len(gr.Str()) + } + + // we found it!, add highlight and continue + if bytePos == byteEnd { + colstart := max(0, graphemeStart-previousLinesOffset) + colend := max(graphemePos-previousLinesOffset, colstart) + + if colend > colstart { + hi.lines[line] = [2]int{colstart, colend} + hi.lineEnd = line + } + } + + highlights = append(highlights, hi) + } + + return highlights +} + +type highlightInfo struct { + // in which line this highlight starts and ends + lineStart, lineEnd int + + // the grapheme highlight ranges for each of these lines + lines map[int][2]int +} + +// coords returns the line x column of this highlight. +func (hi highlightInfo) coords() (int, int, int) { + for i := hi.lineStart; i <= hi.lineEnd; i++ { + hl, ok := hi.lines[i] + if !ok { + continue + } + return i, hl[0], hl[1] + } + return hi.lineStart, 0, 0 +} + +func makeHighlightRanges( + highlights []highlightInfo, + line int, + style lipgloss.Style, +) []lipgloss.Range { + result := []lipgloss.Range{} + for _, hi := range highlights { + lihi, ok := hi.lines[line] + if !ok { + continue + } + if lihi == [2]int{} { + continue + } + result = append(result, lipgloss.NewRange(lihi[0], lihi[1], style)) + } + return result +} diff --git a/packages/tui/internal/viewport/keymap.go b/packages/tui/internal/viewport/keymap.go new file mode 100644 index 00000000..d9c503a9 --- /dev/null +++ b/packages/tui/internal/viewport/keymap.go @@ -0,0 +1,56 @@ +package viewport + +import "github.com/charmbracelet/bubbles/v2/key" + +// KeyMap defines the keybindings for the viewport. Note that you don't +// necessary need to use keybindings at all; the viewport can be controlled +// programmatically with methods like Model.LineDown(1). See the GoDocs for +// details. +type KeyMap struct { + PageDown key.Binding + PageUp key.Binding + HalfPageUp key.Binding + HalfPageDown key.Binding + Down key.Binding + Up key.Binding + Left key.Binding + Right key.Binding +} + +// DefaultKeyMap returns a set of pager-like default keybindings. +func DefaultKeyMap() KeyMap { + return KeyMap{ + PageDown: key.NewBinding( + key.WithKeys("pgdown", "space", "f"), + key.WithHelp("f/pgdn", "page down"), + ), + PageUp: key.NewBinding( + key.WithKeys("pgup", "b"), + key.WithHelp("b/pgup", "page up"), + ), + HalfPageUp: key.NewBinding( + key.WithKeys("u", "ctrl+u"), + key.WithHelp("u", "½ page up"), + ), + HalfPageDown: key.NewBinding( + key.WithKeys("d", "ctrl+d"), + key.WithHelp("d", "½ page down"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑/k", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓/j", "down"), + ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "move left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "move right"), + ), + } +} diff --git a/packages/tui/internal/viewport/viewport.go b/packages/tui/internal/viewport/viewport.go new file mode 100644 index 00000000..aa4c30a3 --- /dev/null +++ b/packages/tui/internal/viewport/viewport.go @@ -0,0 +1,769 @@ +package viewport + +import ( + "math" + "strings" + + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +const ( + defaultHorizontalStep = 6 +) + +// Option is a configuration option that works in conjunction with [New]. For +// example: +// +// timer := New(WithWidth(10, WithHeight(5))) +type Option func(*Model) + +// WithWidth is an initialization option that sets the width of the +// viewport. Pass as an argument to [New]. +func WithWidth(w int) Option { + return func(m *Model) { + m.width = w + } +} + +// WithHeight is an initialization option that sets the height of the +// viewport. Pass as an argument to [New]. +func WithHeight(h int) Option { + return func(m *Model) { + m.height = h + } +} + +// New returns a new model with the given width and height as well as default +// key mappings. +func New(opts ...Option) (m Model) { + for _, opt := range opts { + opt(&m) + } + m.setInitialValues() + return m +} + +// Model is the Bubble Tea model for this viewport element. +type Model struct { + width int + height int + KeyMap KeyMap + + cache string + + // Whether or not to wrap text. If false, it'll allow horizontal scrolling + // instead. + SoftWrap bool + + // Whether or not to fill to the height of the viewport with empty lines. + FillHeight bool + + // Whether or not to respond to the mouse. The mouse must be enabled in + // Bubble Tea for this to work. For details, see the Bubble Tea docs. + MouseWheelEnabled bool + + // The number of lines the mouse wheel will scroll. By default, this is 3. + MouseWheelDelta int + + // YOffset is the vertical scroll position. + YOffset int + + // xOffset is the horizontal scroll position. + xOffset int + + // horizontalStep is the number of columns we move left or right during a + // default horizontal scroll. + horizontalStep int + + // YPosition is the position of the viewport in relation to the terminal + // window. It's used in high performance rendering only. + YPosition int + + // Style applies a lipgloss style to the viewport. Realistically, it's most + // useful for setting borders, margins and padding. + Style lipgloss.Style + + // LeftGutterFunc allows to define a [GutterFunc] that adds a column into + // the left of the viewport, which is kept when horizontal scrolling. + // This can be used for things like line numbers, selection indicators, + // show statuses, etc. + LeftGutterFunc GutterFunc + + initialized bool + lines []string + longestLineWidth int + + // HighlightStyle highlights the ranges set with [SetHighligths]. + HighlightStyle lipgloss.Style + + // SelectedHighlightStyle highlights the highlight range focused during + // navigation. + // Use [SetHighligths] to set the highlight ranges, and [HightlightNext] + // and [HihglightPrevious] to navigate. + SelectedHighlightStyle lipgloss.Style + + // StyleLineFunc allows to return a [lipgloss.Style] for each line. + // The argument is the line index. + StyleLineFunc func(int) lipgloss.Style + + highlights []highlightInfo + hiIdx int +} + +// GutterFunc can be implemented and set into [Model.LeftGutterFunc]. +// +// Example implementation showing line numbers: +// +// func(info GutterContext) string { +// if info.Soft { +// return " │ " +// } +// if info.Index >= info.TotalLines { +// return " ~ │ " +// } +// return fmt.Sprintf("%4d │ ", info.Index+1) +// } +type GutterFunc func(GutterContext) string + +// NoGutter is the default gutter used. +var NoGutter = func(GutterContext) string { return "" } + +// GutterContext provides context to a [GutterFunc]. +type GutterContext struct { + Index int + TotalLines int + Soft bool +} + +func (m *Model) setInitialValues() { + m.KeyMap = DefaultKeyMap() + m.MouseWheelEnabled = true + m.MouseWheelDelta = 3 + m.initialized = true + m.horizontalStep = defaultHorizontalStep + m.LeftGutterFunc = NoGutter +} + +// Init exists to satisfy the tea.Model interface for composability purposes. +func (m Model) Init() tea.Cmd { + return nil +} + +// Height returns the height of the viewport. +func (m Model) Height() int { + return m.height +} + +// SetHeight sets the height of the viewport. +func (m *Model) SetHeight(h int) { + m.height = h +} + +// Width returns the width of the viewport. +func (m Model) Width() int { + return m.width +} + +// SetWidth sets the width of the viewport. +func (m *Model) SetWidth(w int) { + m.width = w +} + +// AtTop returns whether or not the viewport is at the very top position. +func (m Model) AtTop() bool { + return m.YOffset <= 0 +} + +// AtBottom returns whether or not the viewport is at or past the very bottom +// position. +func (m Model) AtBottom() bool { + return m.YOffset >= m.maxYOffset() +} + +// PastBottom returns whether or not the viewport is scrolled beyond the last +// line. This can happen when adjusting the viewport height. +func (m Model) PastBottom() bool { + return m.YOffset > m.maxYOffset() +} + +// ScrollPercent returns the amount scrolled as a float between 0 and 1. +func (m Model) ScrollPercent() float64 { + count := m.lineCount() + if m.Height() >= count { + return 1.0 + } + y := float64(m.YOffset) + h := float64(m.Height()) + t := float64(count) + v := y / (t - h) + return math.Max(0.0, math.Min(1.0, v)) +} + +// HorizontalScrollPercent returns the amount horizontally scrolled as a float +// between 0 and 1. +func (m Model) HorizontalScrollPercent() float64 { + if m.xOffset >= m.longestLineWidth-m.Width() { + return 1.0 + } + y := float64(m.xOffset) + h := float64(m.Width()) + t := float64(m.longestLineWidth) + v := y / (t - h) + return math.Max(0.0, math.Min(1.0, v)) +} + +// SetContent set the pager's text content. +// Line endings will be normalized to '\n'. +func (m *Model) SetContent(s string) { + s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings + m.SetContentLines(strings.Split(s, "\n")) + m.render() +} + +// SetContentLines allows to set the lines to be shown instead of the content. +// If a given line has a \n in it, it'll be considered a [Model.SoftWrap]. +// See also [Model.SetContent]. +func (m *Model) SetContentLines(lines []string) { + // if there's no content, set content to actual nil instead of one empty + // line. + m.lines = lines + if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 { + m.lines = nil + } + m.longestLineWidth = maxLineWidth(m.lines) + m.ClearHighlights() + + if m.YOffset > m.maxYOffset() { + m.GotoBottom() + } + m.render() +} + +// GetContent returns the entire content as a single string. +// Line endings are normalized to '\n'. +func (m Model) GetContent() string { + return strings.Join(m.lines, "\n") +} + +// calculateLine taking soft wraping into account, returns the total viewable +// lines and the real-line index for the given yoffset. +func (m Model) calculateLine(yoffset int) (total, idx int) { + if !m.SoftWrap { + for i, line := range m.lines { + adjust := max(1, lipgloss.Height(line)) + if yoffset >= total && yoffset < total+adjust { + idx = i + } + total += adjust + } + if yoffset >= total { + idx = len(m.lines) + } + return total, idx + } + + maxWidth := m.maxWidth() + var gutterSize int + if m.LeftGutterFunc != nil { + gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + } + for i, line := range m.lines { + adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize)) + if yoffset >= total && yoffset < total+adjust { + idx = i + } + total += adjust + } + if yoffset >= total { + idx = len(m.lines) + } + return total, idx +} + +// lineToIndex taking soft wrappign into account, return the real line index +// for the given line. +func (m Model) lineToIndex(y int) int { + _, idx := m.calculateLine(y) + return idx +} + +// lineCount taking soft wrapping into account, return the total viewable line +// count (real lines + soft wrapped line). +func (m Model) lineCount() int { + total, _ := m.calculateLine(0) + return total +} + +// maxYOffset returns the maximum possible value of the y-offset based on the +// viewport's content and set height. +func (m Model) maxYOffset() int { + return max(0, m.lineCount()-m.Height()+m.Style.GetVerticalFrameSize()) +} + +// maxXOffset returns the maximum possible value of the x-offset based on the +// viewport's content and set width. +func (m Model) maxXOffset() int { + return max(0, m.longestLineWidth-m.Width()) +} + +func (m Model) maxWidth() int { + var gutterSize int + if m.LeftGutterFunc != nil { + gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + } + return m.Width() - + m.Style.GetHorizontalFrameSize() - + gutterSize +} + +func (m Model) maxHeight() int { + return m.Height() - m.Style.GetVerticalFrameSize() +} + +// visibleLines returns the lines that should currently be visible in the +// viewport. +func (m Model) visibleLines() (lines []string) { + maxHeight := m.maxHeight() + maxWidth := m.maxWidth() + + if m.lineCount() > 0 { + pos := m.lineToIndex(m.YOffset) + top := max(0, pos) + bottom := clamp(pos+maxHeight, top, len(m.lines)) + lines = make([]string, bottom-top) + copy(lines, m.lines[top:bottom]) + lines = m.styleLines(lines, top) + lines = m.highlightLines(lines, top) + } + + for m.FillHeight && len(lines) < maxHeight { + lines = append(lines, "") + } + + // if longest line fit within width, no need to do anything else. + if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 { + return m.setupGutter(lines) + } + + if m.SoftWrap { + return m.softWrap(lines, maxWidth) + } + + for i, line := range lines { + sublines := strings.Split(line, "\n") // will only have more than 1 if caller used [Model.SetContentLines]. + for j := range sublines { + sublines[j] = ansi.Cut(sublines[j], m.xOffset, m.xOffset+maxWidth) + } + lines[i] = strings.Join(sublines, "\n") + } + return m.setupGutter(lines) +} + +// styleLines styles the lines using [Model.StyleLineFunc]. +func (m Model) styleLines(lines []string, offset int) []string { + if m.StyleLineFunc == nil { + return lines + } + for i := range lines { + lines[i] = m.StyleLineFunc(i + offset).Render(lines[i]) + } + return lines +} + +// highlightLines highlights the lines with [Model.HighlightStyle] and +// [Model.SelectedHighlightStyle]. +func (m Model) highlightLines(lines []string, offset int) []string { + if len(m.highlights) == 0 { + return lines + } + for i := range lines { + ranges := makeHighlightRanges( + m.highlights, + i+offset, + m.HighlightStyle, + ) + lines[i] = lipgloss.StyleRanges(lines[i], ranges...) + if m.hiIdx < 0 { + continue + } + sel := m.highlights[m.hiIdx] + if hi, ok := sel.lines[i+offset]; ok { + lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange( + hi[0], + hi[1], + m.SelectedHighlightStyle, + )) + } + } + return lines +} + +func (m Model) softWrap(lines []string, maxWidth int) []string { + var wrappedLines []string + total := m.TotalLineCount() + for i, line := range lines { + idx := 0 + for ansi.StringWidth(line) >= idx { + truncatedLine := ansi.Cut(line, idx, maxWidth+idx) + if m.LeftGutterFunc != nil { + truncatedLine = m.LeftGutterFunc(GutterContext{ + Index: i + m.YOffset, + TotalLines: total, + Soft: idx > 0, + }) + truncatedLine + } + wrappedLines = append(wrappedLines, truncatedLine) + idx += maxWidth + } + } + return wrappedLines +} + +// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc]. +func (m Model) setupGutter(lines []string) []string { + if m.LeftGutterFunc == nil { + return lines + } + + offset := max(0, m.lineToIndex(m.YOffset)) + total := m.TotalLineCount() + result := make([]string, len(lines)) + for i := range lines { + var line []string + for j, realLine := range strings.Split(lines[i], "\n") { + line = append(line, m.LeftGutterFunc(GutterContext{ + Index: i + offset, + TotalLines: total, + Soft: j > 0, + })+realLine) + } + result[i] = strings.Join(line, "\n") + } + return result +} + +// SetYOffset sets the Y offset. +func (m *Model) SetYOffset(n int) { + m.YOffset = clamp(n, 0, m.maxYOffset()) +} + +// SetXOffset sets the X offset. +// No-op when soft wrap is enabled. +func (m *Model) SetXOffset(n int) { + if m.SoftWrap { + return + } + m.xOffset = clamp(n, 0, m.maxXOffset()) +} + +// EnsureVisible ensures that the given line and column are in the viewport. +func (m *Model) EnsureVisible(line, colstart, colend int) { + maxWidth := m.maxWidth() + if colend <= maxWidth { + m.SetXOffset(0) + } else { + m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural + } + + if line < m.YOffset || line >= m.YOffset+m.maxHeight() { + m.SetYOffset(line) + } + + m.visibleLines() +} + +// ViewDown moves the view down by the number of lines in the viewport. +// Basically, "page down". +func (m *Model) ViewDown() { + if m.AtBottom() { + return + } + + m.LineDown(m.Height()) + m.render() +} + +// ViewUp moves the view up by one height of the viewport. Basically, "page up". +func (m *Model) ViewUp() { + if m.AtTop() { + return + } + + m.LineUp(m.Height()) + m.render() +} + +// HalfViewDown moves the view down by half the height of the viewport. +func (m *Model) HalfViewDown() { + if m.AtBottom() { + return + } + + m.LineDown(m.Height() / 2) //nolint:mnd + m.render() +} + +// HalfViewUp moves the view up by half the height of the viewport. +func (m *Model) HalfViewUp() { + if m.AtTop() { + return + } + + m.LineUp(m.Height() / 2) //nolint:mnd + m.render() +} + +// LineDown moves the view down by the given number of lines. +func (m *Model) LineDown(n int) { + if m.AtBottom() || n == 0 || len(m.lines) == 0 { + return + } + + // Make sure the number of lines by which we're going to scroll isn't + // greater than the number of lines we actually have left before we reach + // the bottom. + m.SetYOffset(m.YOffset + n) + m.hiIdx = m.findNearedtMatch() + m.render() +} + +// LineUp moves the view down by the given number of lines. Returns the new +// lines to show. +func (m *Model) LineUp(n int) { + if m.AtTop() || n == 0 || len(m.lines) == 0 { + return + } + + // Make sure the number of lines by which we're going to scroll isn't + // greater than the number of lines we are from the top. + m.SetYOffset(m.YOffset - n) + m.hiIdx = m.findNearedtMatch() + m.render() +} + +// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport. +func (m Model) TotalLineCount() int { + return m.lineCount() +} + +// VisibleLineCount returns the number of the visible lines within the viewport. +func (m Model) VisibleLineCount() int { + return len(m.visibleLines()) +} + +// GotoTop sets the viewport to the top position. +func (m *Model) GotoTop() (lines []string) { + if m.AtTop() { + return nil + } + + m.SetYOffset(0) + m.hiIdx = m.findNearedtMatch() + m.render() + return m.visibleLines() +} + +// GotoBottom sets the viewport to the bottom position. +func (m *Model) GotoBottom() (lines []string) { + m.SetYOffset(m.maxYOffset()) + m.hiIdx = m.findNearedtMatch() + m.render() + return m.visibleLines() +} + +// SetHorizontalStep sets the amount of cells that the viewport moves in the +// default viewport keymapping. If set to 0 or less, horizontal scrolling is +// disabled. +func (m *Model) SetHorizontalStep(n int) { + if n < 0 { + n = 0 + } + + m.horizontalStep = n +} + +// MoveLeft moves the viewport to the left by the given number of columns. +func (m *Model) MoveLeft(cols int) { + m.xOffset -= cols + if m.xOffset < 0 { + m.xOffset = 0 + } +} + +// MoveRight moves viewport to the right by the given number of columns. +func (m *Model) MoveRight(cols int) { + // prevents over scrolling to the right + w := m.maxWidth() + if m.xOffset > m.longestLineWidth-w { + return + } + m.xOffset += cols +} + +// Resets lines indent to zero. +func (m *Model) ResetIndent() { + m.xOffset = 0 +} + +// SetHighlights sets ranges of characters to highlight. +// For instance, `[]int{[]int{2, 10}, []int{20, 30}}` will highlight characters +// 2 to 10 and 20 to 30. +// Note that highlights are not expected to transpose each other, and are also +// expected to be in order. +// Use [Model.SetHighlights] to set the highlight ranges, and +// [Model.HighlightNext] and [Model.HighlightPrevious] to navigate. +// Use [Model.ClearHighlights] to remove all highlights. +func (m *Model) SetHighlights(matches [][]int) { + if len(matches) == 0 || len(m.lines) == 0 { + return + } + m.highlights = parseMatches(m.GetContent(), matches) + m.hiIdx = m.findNearedtMatch() + m.showHighlight() +} + +// ClearHighlights clears previously set highlights. +func (m *Model) ClearHighlights() { + m.highlights = nil + m.hiIdx = -1 +} + +func (m *Model) showHighlight() { + if m.hiIdx == -1 { + return + } + line, colstart, colend := m.highlights[m.hiIdx].coords() + m.EnsureVisible(line, colstart, colend) +} + +// HighlightNext highlights the next match. +func (m *Model) HighlightNext() { + if m.highlights == nil { + return + } + + m.hiIdx = (m.hiIdx + 1) % len(m.highlights) + m.showHighlight() +} + +// HighlightPrevious highlights the previous match. +func (m *Model) HighlightPrevious() { + if m.highlights == nil { + return + } + + m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights) + m.showHighlight() +} + +func (m Model) findNearedtMatch() int { + for i, match := range m.highlights { + if match.lineStart >= m.YOffset { + return i + } + } + return -1 +} + +// Update handles standard message-based viewport updates. +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + m = m.updateAsModel(msg) + return m, nil +} + +// Author's note: this method has been broken out to make it easier to +// potentially transition Update to satisfy tea.Model. +func (m Model) updateAsModel(msg tea.Msg) Model { + if !m.initialized { + m.setInitialValues() + } + + switch msg := msg.(type) { + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.KeyMap.PageDown): + m.ViewDown() + + case key.Matches(msg, m.KeyMap.PageUp): + m.ViewUp() + + case key.Matches(msg, m.KeyMap.HalfPageDown): + m.HalfViewDown() + + case key.Matches(msg, m.KeyMap.HalfPageUp): + m.HalfViewUp() + + case key.Matches(msg, m.KeyMap.Down): + m.LineDown(1) + + case key.Matches(msg, m.KeyMap.Up): + m.LineUp(1) + + case key.Matches(msg, m.KeyMap.Left): + m.MoveLeft(m.horizontalStep) + + case key.Matches(msg, m.KeyMap.Right): + m.MoveRight(m.horizontalStep) + } + + case tea.MouseWheelMsg: + if !m.MouseWheelEnabled { + break + } + + switch msg.Button { + case tea.MouseWheelDown: + m.LineDown(m.MouseWheelDelta) + + case tea.MouseWheelUp: + m.LineUp(m.MouseWheelDelta) + } + } + + return m +} + +// View renders the viewport into a string. +func (m *Model) render() { + w, h := m.Width(), m.Height() + if sw := m.Style.GetWidth(); sw != 0 { + w = min(w, sw) + } + if sh := m.Style.GetHeight(); sh != 0 { + h = min(h, sh) + } + contentWidth := w - m.Style.GetHorizontalFrameSize() + contentHeight := h - m.Style.GetVerticalFrameSize() + visible := m.visibleLines() + contents := lipgloss.NewStyle(). + Width(contentWidth). // pad to width. + Height(contentHeight). // pad to height. + MaxHeight(contentHeight). // truncate height if taller. + MaxWidth(contentWidth). // truncate width if wider. + Render(strings.Join(visible, "\n")) + m.cache = m.Style. + UnsetWidth().UnsetHeight(). // Style size already applied in contents. + Render(contents) +} + +func (m Model) View() string { + return m.cache +} + +func clamp(v, low, high int) int { + if high < low { + low, high = high, low + } + return min(high, max(low, v)) +} + +func maxLineWidth(lines []string) int { + result := 0 + for _, line := range lines { + result = max(result, lipgloss.Width(line)) + } + return result +}