viewport.go

  1package viewport
  2
  3import (
  4	"math"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/lipgloss/v2"
 10	"github.com/charmbracelet/x/ansi"
 11)
 12
 13const (
 14	defaultHorizontalStep = 6
 15)
 16
 17// Option is a configuration option that works in conjunction with [New]. For
 18// example:
 19//
 20//	timer := New(WithWidth(10, WithHeight(5)))
 21type Option func(*Model)
 22
 23// WithWidth is an initialization option that sets the width of the
 24// viewport. Pass as an argument to [New].
 25func WithWidth(w int) Option {
 26	return func(m *Model) {
 27		m.width = w
 28	}
 29}
 30
 31// WithHeight is an initialization option that sets the height of the
 32// viewport. Pass as an argument to [New].
 33func WithHeight(h int) Option {
 34	return func(m *Model) {
 35		m.height = h
 36	}
 37}
 38
 39// New returns a new model with the given width and height as well as default
 40// key mappings.
 41func New(opts ...Option) (m Model) {
 42	for _, opt := range opts {
 43		opt(&m)
 44	}
 45	m.setInitialValues()
 46	return m
 47}
 48
 49// Model is the Bubble Tea model for this viewport element.
 50type Model struct {
 51	width  int
 52	height int
 53	KeyMap KeyMap
 54
 55	// Whether or not to wrap text. If false, it'll allow horizontal scrolling
 56	// instead.
 57	SoftWrap bool
 58
 59	// Whether or not to fill to the height of the viewport with empty lines.
 60	FillHeight bool
 61
 62	// Whether or not to respond to the mouse. The mouse must be enabled in
 63	// Bubble Tea for this to work. For details, see the Bubble Tea docs.
 64	MouseWheelEnabled bool
 65
 66	// The number of lines the mouse wheel will scroll. By default, this is 3.
 67	MouseWheelDelta int
 68
 69	// yOffset is the vertical scroll position.
 70	yOffset int
 71
 72	// xOffset is the horizontal scroll position.
 73	xOffset int
 74
 75	// horizontalStep is the number of columns we move left or right during a
 76	// default horizontal scroll.
 77	horizontalStep int
 78
 79	// YPosition is the position of the viewport in relation to the terminal
 80	// window. It's used in high performance rendering only.
 81	YPosition int
 82
 83	// Style applies a lipgloss style to the viewport. Realistically, it's most
 84	// useful for setting borders, margins and padding.
 85	Style lipgloss.Style
 86
 87	// LeftGutterFunc allows to define a [GutterFunc] that adds a column into
 88	// the left of the viewport, which is kept when horizontal scrolling.
 89	// This can be used for things like line numbers, selection indicators,
 90	// show statuses, etc.
 91	LeftGutterFunc GutterFunc
 92
 93	initialized      bool
 94	lines            []string
 95	longestLineWidth int
 96
 97	// HighlightStyle highlights the ranges set with [SetHighligths].
 98	HighlightStyle lipgloss.Style
 99
100	// SelectedHighlightStyle highlights the highlight range focused during
101	// navigation.
102	// Use [SetHighligths] to set the highlight ranges, and [HightlightNext]
103	// and [HihglightPrevious] to navigate.
104	SelectedHighlightStyle lipgloss.Style
105
106	// StyleLineFunc allows to return a [lipgloss.Style] for each line.
107	// The argument is the line index.
108	StyleLineFunc func(int) lipgloss.Style
109
110	highlights []highlightInfo
111	hiIdx      int
112}
113
114// GutterFunc can be implemented and set into [Model.LeftGutterFunc].
115//
116// Example implementation showing line numbers:
117//
118//	func(info GutterContext) string {
119//		if info.Soft {
120//			return "     │ "
121//		}
122//		if info.Index >= info.TotalLines {
123//			return "   ~ │ "
124//		}
125//		return fmt.Sprintf("%4d │ ", info.Index+1)
126//	}
127type GutterFunc func(GutterContext) string
128
129// NoGutter is the default gutter used.
130var NoGutter = func(GutterContext) string { return "" }
131
132// GutterContext provides context to a [GutterFunc].
133type GutterContext struct {
134	Index      int
135	TotalLines int
136	Soft       bool
137}
138
139func (m *Model) setInitialValues() {
140	m.KeyMap = DefaultKeyMap()
141	m.MouseWheelEnabled = true
142	m.MouseWheelDelta = 3
143	m.horizontalStep = defaultHorizontalStep
144	m.LeftGutterFunc = NoGutter
145	m.initialized = true
146}
147
148// Init exists to satisfy the tea.Model interface for composability purposes.
149func (m Model) Init() tea.Cmd {
150	return nil
151}
152
153// Height returns the height of the viewport.
154func (m Model) Height() int {
155	return m.height
156}
157
158// SetHeight sets the height of the viewport.
159func (m *Model) SetHeight(h int) {
160	m.height = h
161}
162
163// Width returns the width of the viewport.
164func (m Model) Width() int {
165	return m.width
166}
167
168// SetWidth sets the width of the viewport.
169func (m *Model) SetWidth(w int) {
170	m.width = w
171}
172
173// AtTop returns whether or not the viewport is at the very top position.
174func (m Model) AtTop() bool {
175	return m.YOffset() <= 0
176}
177
178// AtBottom returns whether or not the viewport is at or past the very bottom
179// position.
180func (m Model) AtBottom() bool {
181	return m.YOffset() >= m.maxYOffset()
182}
183
184// PastBottom returns whether or not the viewport is scrolled beyond the last
185// line. This can happen when adjusting the viewport height.
186func (m Model) PastBottom() bool {
187	return m.YOffset() > m.maxYOffset()
188}
189
190// ScrollPercent returns the amount scrolled as a float between 0 and 1.
191func (m Model) ScrollPercent() float64 {
192	count := m.lineCount()
193	if m.Height() >= count {
194		return 1.0
195	}
196	y := float64(m.YOffset())
197	h := float64(m.Height())
198	t := float64(count)
199	v := y / (t - h)
200	return math.Max(0.0, math.Min(1.0, v))
201}
202
203// HorizontalScrollPercent returns the amount horizontally scrolled as a float
204// between 0 and 1.
205func (m Model) HorizontalScrollPercent() float64 {
206	if m.xOffset >= m.longestLineWidth-m.Width() {
207		return 1.0
208	}
209	y := float64(m.xOffset)
210	h := float64(m.Width())
211	t := float64(m.longestLineWidth)
212	v := y / (t - h)
213	return math.Max(0.0, math.Min(1.0, v))
214}
215
216// SetContent set the pager's text content.
217// Line endings will be normalized to '\n'.
218func (m *Model) SetContent(s string) {
219	s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings
220	m.SetContentLines(strings.Split(s, "\n"))
221}
222
223// SetContentLines allows to set the lines to be shown instead of the content.
224// If a given line has a \n in it, it'll be considered a [Model.SoftWrap].
225// See also [Model.SetContent].
226func (m *Model) SetContentLines(lines []string) {
227	// if there's no content, set content to actual nil instead of one empty
228	// line.
229	m.lines = lines
230	if len(m.lines) == 1 && ansi.StringWidth(m.lines[0]) == 0 {
231		m.lines = nil
232	}
233	m.longestLineWidth = maxLineWidth(m.lines)
234	m.ClearHighlights()
235
236	if m.YOffset() > m.maxYOffset() {
237		m.GotoBottom()
238	}
239}
240
241// GetContent returns the entire content as a single string.
242// Line endings are normalized to '\n'.
243func (m Model) GetContent() string {
244	return strings.Join(m.lines, "\n")
245}
246
247// calculateLine taking soft wraping into account, returns the total viewable
248// lines and the real-line index for the given yoffset.
249func (m Model) calculateLine(yoffset int) (total, idx int) {
250	if !m.SoftWrap {
251		for i, line := range m.lines {
252			adjust := max(1, lipgloss.Height(line))
253			if yoffset >= total && yoffset < total+adjust {
254				idx = i
255			}
256			total += adjust
257		}
258		if yoffset >= total {
259			idx = len(m.lines)
260		}
261		return total, idx
262	}
263
264	maxWidth := m.maxWidth()
265	var gutterSize int
266	if m.LeftGutterFunc != nil {
267		gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
268	}
269	for i, line := range m.lines {
270		adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize))
271		if yoffset >= total && yoffset < total+adjust {
272			idx = i
273		}
274		total += adjust
275	}
276	if yoffset >= total {
277		idx = len(m.lines)
278	}
279	return total, idx
280}
281
282// lineToIndex taking soft wrappign into account, return the real line index
283// for the given line.
284func (m Model) lineToIndex(y int) int {
285	_, idx := m.calculateLine(y)
286	return idx
287}
288
289// lineCount taking soft wrapping into account, return the total viewable line
290// count (real lines + soft wrapped line).
291func (m Model) lineCount() int {
292	total, _ := m.calculateLine(0)
293	return total
294}
295
296// maxYOffset returns the maximum possible value of the y-offset based on the
297// viewport's content and set height.
298func (m Model) maxYOffset() int {
299	return max(0, m.lineCount()-m.Height()+m.Style.GetVerticalFrameSize())
300}
301
302// maxXOffset returns the maximum possible value of the x-offset based on the
303// viewport's content and set width.
304func (m Model) maxXOffset() int {
305	return max(0, m.longestLineWidth-m.Width())
306}
307
308func (m Model) maxWidth() int {
309	var gutterSize int
310	if m.LeftGutterFunc != nil {
311		gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
312	}
313	return m.Width() -
314		m.Style.GetHorizontalFrameSize() -
315		gutterSize
316}
317
318func (m Model) maxHeight() int {
319	return m.Height() - m.Style.GetVerticalFrameSize()
320}
321
322// visibleLines returns the lines that should currently be visible in the
323// viewport.
324func (m Model) visibleLines() (lines []string) {
325	maxHeight := m.maxHeight()
326	maxWidth := m.maxWidth()
327
328	if m.lineCount() > 0 {
329		pos := m.lineToIndex(m.YOffset())
330		top := max(0, pos)
331		bottom := clamp(pos+maxHeight, top, len(m.lines))
332		lines = make([]string, bottom-top)
333		copy(lines, m.lines[top:bottom])
334		lines = m.styleLines(lines, top)
335		lines = m.highlightLines(lines, top)
336	}
337
338	for m.FillHeight && len(lines) < maxHeight {
339		lines = append(lines, "")
340	}
341
342	// if longest line fit within width, no need to do anything else.
343	if (m.xOffset == 0 && m.longestLineWidth <= maxWidth) || maxWidth == 0 {
344		return m.setupGutter(lines)
345	}
346
347	if m.SoftWrap {
348		return m.softWrap(lines, maxWidth)
349	}
350
351	for i, line := range lines {
352		sublines := strings.Split(line, "\n") // will only have more than 1 if caller used [Model.SetContentLines].
353		for j := range sublines {
354			sublines[j] = ansi.Cut(sublines[j], m.xOffset, m.xOffset+maxWidth)
355		}
356		lines[i] = strings.Join(sublines, "\n")
357	}
358	return m.setupGutter(lines)
359}
360
361// styleLines styles the lines using [Model.StyleLineFunc].
362func (m Model) styleLines(lines []string, offset int) []string {
363	if m.StyleLineFunc == nil {
364		return lines
365	}
366	for i := range lines {
367		lines[i] = m.StyleLineFunc(i + offset).Render(lines[i])
368	}
369	return lines
370}
371
372// highlightLines highlights the lines with [Model.HighlightStyle] and
373// [Model.SelectedHighlightStyle].
374func (m Model) highlightLines(lines []string, offset int) []string {
375	if len(m.highlights) == 0 {
376		return lines
377	}
378	for i := range lines {
379		ranges := makeHighlightRanges(
380			m.highlights,
381			i+offset,
382			m.HighlightStyle,
383		)
384		lines[i] = lipgloss.StyleRanges(lines[i], ranges...)
385		if m.hiIdx < 0 {
386			continue
387		}
388		sel := m.highlights[m.hiIdx]
389		if hi, ok := sel.lines[i+offset]; ok {
390			lines[i] = lipgloss.StyleRanges(lines[i], lipgloss.NewRange(
391				hi[0],
392				hi[1],
393				m.SelectedHighlightStyle,
394			))
395		}
396	}
397	return lines
398}
399
400func (m Model) softWrap(lines []string, maxWidth int) []string {
401	var wrappedLines []string
402	total := m.TotalLineCount()
403	for i, line := range lines {
404		idx := 0
405		for ansi.StringWidth(line) >= idx {
406			truncatedLine := ansi.Cut(line, idx, maxWidth+idx)
407			if m.LeftGutterFunc != nil {
408				truncatedLine = m.LeftGutterFunc(GutterContext{
409					Index:      i + m.YOffset(),
410					TotalLines: total,
411					Soft:       idx > 0,
412				}) + truncatedLine
413			}
414			wrappedLines = append(wrappedLines, truncatedLine)
415			idx += maxWidth
416		}
417	}
418	return wrappedLines
419}
420
421// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc].
422func (m Model) setupGutter(lines []string) []string {
423	if m.LeftGutterFunc == nil {
424		return lines
425	}
426
427	offset := max(0, m.lineToIndex(m.YOffset()))
428	total := m.TotalLineCount()
429	result := make([]string, len(lines))
430	for i := range lines {
431		var line []string
432		for j, realLine := range strings.Split(lines[i], "\n") {
433			line = append(line, m.LeftGutterFunc(GutterContext{
434				Index:      i + offset,
435				TotalLines: total,
436				Soft:       j > 0,
437			})+realLine)
438		}
439		result[i] = strings.Join(line, "\n")
440	}
441	return result
442}
443
444// SetYOffset sets the Y offset.
445func (m *Model) SetYOffset(n int) {
446	m.yOffset = clamp(n, 0, m.maxYOffset())
447}
448
449// YOffset returns the current Y offset - the vertical scroll position.
450func (m *Model) YOffset() int { return m.yOffset }
451
452// EnsureVisible ensures that the given line and column are in the viewport.
453func (m *Model) EnsureVisible(line, colstart, colend int) {
454	maxWidth := m.maxWidth()
455	if colend <= maxWidth {
456		m.SetXOffset(0)
457	} else {
458		m.SetXOffset(colstart - m.horizontalStep) // put one step to the left, feels more natural
459	}
460
461	if line < m.YOffset() || line >= m.YOffset()+m.maxHeight() {
462		m.SetYOffset(line)
463	}
464
465	m.visibleLines()
466}
467
468// PageDown moves the view down by the number of lines in the viewport.
469func (m *Model) PageDown() {
470	if m.AtBottom() {
471		return
472	}
473
474	m.ScrollDown(m.Height())
475}
476
477// PageUp moves the view up by one height of the viewport.
478func (m *Model) PageUp() {
479	if m.AtTop() {
480		return
481	}
482
483	m.ScrollUp(m.Height())
484}
485
486// HalfPageDown moves the view down by half the height of the viewport.
487func (m *Model) HalfPageDown() {
488	if m.AtBottom() {
489		return
490	}
491
492	m.ScrollDown(m.Height() / 2) //nolint:mnd
493}
494
495// HalfPageUp moves the view up by half the height of the viewport.
496func (m *Model) HalfPageUp() {
497	if m.AtTop() {
498		return
499	}
500
501	m.ScrollUp(m.Height() / 2) //nolint:mnd
502}
503
504// ScrollDown moves the view down by the given number of lines.
505func (m *Model) ScrollDown(n int) {
506	if m.AtBottom() || n == 0 || len(m.lines) == 0 {
507		return
508	}
509
510	// Make sure the number of lines by which we're going to scroll isn't
511	// greater than the number of lines we actually have left before we reach
512	// the bottom.
513	m.SetYOffset(m.YOffset() + n)
514	m.hiIdx = m.findNearedtMatch()
515}
516
517// ScrollUp moves the view up by the given number of lines. Returns the new
518// lines to show.
519func (m *Model) ScrollUp(n int) {
520	if m.AtTop() || n == 0 || len(m.lines) == 0 {
521		return
522	}
523
524	// Make sure the number of lines by which we're going to scroll isn't
525	// greater than the number of lines we are from the top.
526	m.SetYOffset(m.YOffset() - n)
527	m.hiIdx = m.findNearedtMatch()
528}
529
530// SetHorizontalStep sets the amount of cells that the viewport moves in the
531// default viewport keymapping. If set to 0 or less, horizontal scrolling is
532// disabled.
533func (m *Model) SetHorizontalStep(n int) {
534	m.horizontalStep = max(0, n)
535}
536
537// XOffset returns the current X offset - the horizontal scroll position.
538func (m *Model) XOffset() int { return m.xOffset }
539
540// SetXOffset sets the X offset.
541// No-op when soft wrap is enabled.
542func (m *Model) SetXOffset(n int) {
543	if m.SoftWrap {
544		return
545	}
546	m.xOffset = clamp(n, 0, m.maxXOffset())
547}
548
549// ScrollLeft moves the viewport to the left by the given number of columns.
550func (m *Model) ScrollLeft(n int) {
551	m.SetXOffset(m.xOffset - n)
552}
553
554// ScrollRight moves viewport to the right by the given number of columns.
555func (m *Model) ScrollRight(n int) {
556	m.SetXOffset(m.xOffset + n)
557}
558
559// TotalLineCount returns the total number of lines (both hidden and visible) within the viewport.
560func (m Model) TotalLineCount() int {
561	return m.lineCount()
562}
563
564// VisibleLineCount returns the number of the visible lines within the viewport.
565func (m Model) VisibleLineCount() int {
566	return len(m.visibleLines())
567}
568
569// GotoTop sets the viewport to the top position.
570func (m *Model) GotoTop() (lines []string) {
571	if m.AtTop() {
572		return nil
573	}
574
575	m.SetYOffset(0)
576	m.hiIdx = m.findNearedtMatch()
577	return m.visibleLines()
578}
579
580// GotoBottom sets the viewport to the bottom position.
581func (m *Model) GotoBottom() (lines []string) {
582	m.SetYOffset(m.maxYOffset())
583	m.hiIdx = m.findNearedtMatch()
584	return m.visibleLines()
585}
586
587// SetHighlights sets ranges of characters to highlight.
588// For instance, `[]int{[]int{2, 10}, []int{20, 30}}` will highlight characters
589// 2 to 10 and 20 to 30.
590// Note that highlights are not expected to transpose each other, and are also
591// expected to be in order.
592// Use [Model.SetHighlights] to set the highlight ranges, and
593// [Model.HighlightNext] and [Model.HighlightPrevious] to navigate.
594// Use [Model.ClearHighlights] to remove all highlights.
595func (m *Model) SetHighlights(matches [][]int) {
596	if len(matches) == 0 || len(m.lines) == 0 {
597		return
598	}
599	m.highlights = parseMatches(m.GetContent(), matches)
600	m.hiIdx = m.findNearedtMatch()
601	m.showHighlight()
602}
603
604// ClearHighlights clears previously set highlights.
605func (m *Model) ClearHighlights() {
606	m.highlights = nil
607	m.hiIdx = -1
608}
609
610func (m *Model) showHighlight() {
611	if m.hiIdx == -1 {
612		return
613	}
614	line, colstart, colend := m.highlights[m.hiIdx].coords()
615	m.EnsureVisible(line, colstart, colend)
616}
617
618// HighlightNext highlights the next match.
619func (m *Model) HighlightNext() {
620	if m.highlights == nil {
621		return
622	}
623
624	m.hiIdx = (m.hiIdx + 1) % len(m.highlights)
625	m.showHighlight()
626}
627
628// HighlightPrevious highlights the previous match.
629func (m *Model) HighlightPrevious() {
630	if m.highlights == nil {
631		return
632	}
633
634	m.hiIdx = (m.hiIdx - 1 + len(m.highlights)) % len(m.highlights)
635	m.showHighlight()
636}
637
638func (m Model) findNearedtMatch() int {
639	for i, match := range m.highlights {
640		if match.lineStart >= m.YOffset() {
641			return i
642		}
643	}
644	return -1
645}
646
647// Update handles standard message-based viewport updates.
648func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
649	m = m.updateAsModel(msg)
650	return m, nil
651}
652
653// Author's note: this method has been broken out to make it easier to
654// potentially transition Update to satisfy tea.Model.
655func (m Model) updateAsModel(msg tea.Msg) Model {
656	if !m.initialized {
657		m.setInitialValues()
658	}
659
660	switch msg := msg.(type) {
661	case tea.KeyPressMsg:
662		switch {
663		case key.Matches(msg, m.KeyMap.PageDown):
664			m.PageDown()
665
666		case key.Matches(msg, m.KeyMap.PageUp):
667			m.PageUp()
668
669		case key.Matches(msg, m.KeyMap.HalfPageDown):
670			m.HalfPageDown()
671
672		case key.Matches(msg, m.KeyMap.HalfPageUp):
673			m.HalfPageUp()
674
675		case key.Matches(msg, m.KeyMap.Down):
676			m.ScrollDown(1)
677
678		case key.Matches(msg, m.KeyMap.Up):
679			m.ScrollUp(1)
680
681		case key.Matches(msg, m.KeyMap.Left):
682			m.ScrollLeft(m.horizontalStep)
683
684		case key.Matches(msg, m.KeyMap.Right):
685			m.ScrollRight(m.horizontalStep)
686		}
687
688	case tea.MouseWheelMsg:
689		if !m.MouseWheelEnabled {
690			break
691		}
692		switch msg.Button {
693		case tea.MouseWheelDown:
694			// NOTE: some terminal emulators don't send the shift event for
695			// mouse actions.
696			if msg.Mod.Contains(tea.ModShift) {
697				m.ScrollRight(m.horizontalStep)
698				break
699			}
700			m.ScrollDown(m.MouseWheelDelta)
701		case tea.MouseWheelUp:
702			// NOTE: some terminal emulators don't send the shift event for
703			// mouse actions.
704			if msg.Mod.Contains(tea.ModShift) {
705				m.ScrollLeft(m.horizontalStep)
706				break
707			}
708			m.ScrollUp(m.MouseWheelDelta)
709		case tea.MouseWheelLeft:
710			m.ScrollLeft(m.horizontalStep)
711		case tea.MouseWheelRight:
712			m.ScrollRight(m.horizontalStep)
713		}
714	}
715
716	return m
717}
718
719// View renders the viewport into a string.
720func (m Model) View() string {
721	w, h := m.Width(), m.Height()
722	if sw := m.Style.GetWidth(); sw != 0 {
723		w = min(w, sw)
724	}
725	if sh := m.Style.GetHeight(); sh != 0 {
726		h = min(h, sh)
727	}
728	contentWidth := w - m.Style.GetHorizontalFrameSize()
729	contentHeight := h - m.Style.GetVerticalFrameSize()
730	contents := lipgloss.NewStyle().
731		Width(contentWidth).      // pad to width.
732		Height(contentHeight).    // pad to height.
733		MaxHeight(contentHeight). // truncate height if taller.
734		MaxWidth(contentWidth).   // truncate width if wider.
735		Render(strings.Join(m.visibleLines(), "\n"))
736	return m.Style.
737		UnsetWidth().UnsetHeight(). // Style size already applied in contents.
738		Render(contents)
739}
740
741func clamp(v, low, high int) int {
742	if high < low {
743		low, high = high, low
744	}
745	return min(high, max(low, v))
746}
747
748func maxLineWidth(lines []string) int {
749	result := 0
750	for _, line := range lines {
751		result = max(result, lipgloss.Width(line))
752	}
753	return result
754}