Просмотр исходного кода

shitty hack for terrible charm bubbletea performance

Dax Raad 7 месяцев назад
Родитель
Сommit
4699739814

+ 2 - 2
bun.lock

@@ -493,7 +493,7 @@
 
     "@types/babel__traverse": ["@types/[email protected]", "", { "dependencies": { "@babel/types": "^7.20.7" } }, "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng=="],
 
-    "@types/bun": ["@types/[email protected]8", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
+    "@types/bun": ["@types/[email protected]9", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
 
     "@types/debug": ["@types/[email protected]", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
 
@@ -627,7 +627,7 @@
 
     "buffer": ["[email protected]", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4", "isarray": "^1.0.0" } }, "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg=="],
 
-    "bun-types": ["[email protected]8", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
+    "bun-types": ["[email protected]9", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
 
     "bundle-name": ["[email protected]", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
 

+ 1 - 0
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

+ 2 - 0
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=

+ 17 - 7
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 {

+ 1 - 1
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 {

+ 1 - 1
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

+ 7 - 0
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()

+ 2 - 2
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...)
 	}
 }

+ 141 - 0
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
+}

+ 56 - 0
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"),
+		),
+	}
+}

+ 769 - 0
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
+}