summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-07-19 15:00:11 -0400
committerDax Raad <[email protected]>2025-07-19 15:00:11 -0400
commit4699739814cc7e57a0eef71990bd1ef502cc33c8 (patch)
tree1fc2a609a1b96d51bf5254177697c0c927b6818c /packages
parentc1d87c32a2df8f9e27270ac46107f767caf38a1f (diff)
downloadopencode-4699739814cc7e57a0eef71990bd1ef502cc33c8.tar.gz
opencode-4699739814cc7e57a0eef71990bd1ef502cc33c8.zip
shitty hack for terrible charm bubbletea performance
Diffstat (limited to 'packages')
-rw-r--r--packages/tui/go.mod1
-rw-r--r--packages/tui/go.sum2
-rw-r--r--packages/tui/internal/components/chat/messages.go24
-rw-r--r--packages/tui/internal/components/dialog/help.go2
-rw-r--r--packages/tui/internal/components/fileviewer/fileviewer.go2
-rw-r--r--packages/tui/internal/tui/tui.go7
-rw-r--r--packages/tui/internal/util/util.go4
-rw-r--r--packages/tui/internal/viewport/highlight.go141
-rw-r--r--packages/tui/internal/viewport/keymap.go56
-rw-r--r--packages/tui/internal/viewport/viewport.go769
10 files changed, 997 insertions, 11 deletions
diff --git a/packages/tui/go.mod b/packages/tui/go.mod
index e23e05c30..0b4698383 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 370ea7121..f41abaf42 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 11a3bcd9f..9b6920adf 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 80123165a..15931724b 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 6627bc3f0..3fa333f4b 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 a81c1a6ba..d09f3d343 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 c7898acb5..fdefb2901 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 000000000..ec0ffda56
--- /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 000000000..d9c503a9f
--- /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 000000000..aa4c30a3f
--- /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
+}