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}