shitty hack for terrible charm bubbletea performance

This commit is contained in:
Dax Raad 2025-07-19 15:00:11 -04:00
parent c1d87c32a2
commit 4699739814
11 changed files with 999 additions and 13 deletions

View file

@ -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=="],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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...)
}
}

View file

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

View file

@ -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"),
),
}
}

View file

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