From 35ab0d6ce52cc35dca767bc8a4e5f3394deee968 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Thu, 7 Aug 2025 02:22:13 -0600 Subject: [PATCH 1/4] fix(viewport): refactor softwrap; improve perf; various bug fixes Signed-off-by: Liam Stanley --- go.mod | 4 +- go.sum | 4 + .../view-40x1-softwrap-at-bottom.golden | 3 + .../view-40x1-softwrap-scrolled-plus-1.golden | 3 + .../view-40x1-softwrap-scrolled-plus-2.golden | 3 + .../TestSizing/view-40x1-softwrap.golden | 3 + viewport/testdata/TestSizing/view-40x1.golden | 3 + .../TestSizing/view-40x100percent.golden | 19 ++ .../view-50x15-content-lines.golden | 15 + .../view-50x15-softwrap-at-bottom.golden | 15 + .../view-50x15-softwrap-at-top.golden | 15 + ...iew-50x15-softwrap-gutter-at-bottom.golden | 15 + .../view-50x15-softwrap-gutter-at-top.golden | 15 + ...x15-softwrap-gutter-scrolled-plus-1.golden | 15 + ...x15-softwrap-gutter-scrolled-plus-2.golden | 15 + ...view-50x15-softwrap-scrolled-plus-1.golden | 15 + ...view-50x15-softwrap-scrolled-plus-2.golden | 15 + viewport/viewport.go | 265 +++++++++++------- viewport/viewport_test.go | 255 ++++++++++++++--- 19 files changed, 553 insertions(+), 144 deletions(-) create mode 100644 viewport/testdata/TestSizing/view-40x1-softwrap-at-bottom.golden create mode 100644 viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-1.golden create mode 100644 viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-2.golden create mode 100644 viewport/testdata/TestSizing/view-40x1-softwrap.golden create mode 100644 viewport/testdata/TestSizing/view-40x1.golden create mode 100644 viewport/testdata/TestSizing/view-40x100percent.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-content-lines.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-at-bottom.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-at-top.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-1.golden create mode 100644 viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-2.golden diff --git a/go.mod b/go.mod index 2f022a5f7..a944e6b96 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/charmbracelet/x/ansi v0.8.0 - github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a + github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.16 @@ -18,7 +18,7 @@ require ( ) require ( - github.com/aymanbagabas/go-udiff v0.2.0 // indirect + github.com/aymanbagabas/go-udiff v0.3.1 // indirect github.com/charmbracelet/colorprofile v0.3.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/input v0.3.4 // indirect diff --git a/go.sum b/go.sum index 5d01a6674..d052c8f05 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1 h1:RvpXiXuPAuaKCHPCsE/lK5+zztnNDTSCa0CpeeIKdDU= github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.1/go.mod h1:qbcZLI5z8R49v9xBdU5V5Dh5D2uccx8wSwBqxQyErqc= github.com/charmbracelet/colorprofile v0.3.0 h1:KtLh9uuu1RCt+Hml4s6Hz+kB1PfV3wi++1h5ia65yKQ= @@ -18,6 +20,8 @@ github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/input v0.3.4 h1:Mujmnv/4DaitU0p+kIsrlfZl/UlmeLKw1wAP3e1fMN0= github.com/charmbracelet/x/input v0.3.4/go.mod h1:JI8RcvdZWQIhn09VzeK3hdp4lTz7+yhiEdpEQtZN+2c= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= diff --git a/viewport/testdata/TestSizing/view-40x1-softwrap-at-bottom.golden b/viewport/testdata/TestSizing/view-40x1-softwrap-at-bottom.golden new file mode 100644 index 000000000..d76bc057a --- /dev/null +++ b/viewport/testdata/TestSizing/view-40x1-softwrap-at-bottom.golden @@ -0,0 +1,3 @@ +╭────────────────────────────────────────╮ +│ll know how many foes you've defeated. │ +╰────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-1.golden b/viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-1.golden new file mode 100644 index 000000000..07c2c3b55 --- /dev/null +++ b/viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-1.golden @@ -0,0 +1,3 @@ +╭────────────────────────────────────────╮ +│cter Zote from an awesome "Hollow knight│ +╰────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-2.golden b/viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-2.golden new file mode 100644 index 000000000..a138c5126 --- /dev/null +++ b/viewport/testdata/TestSizing/view-40x1-softwrap-scrolled-plus-2.golden @@ -0,0 +1,3 @@ +╭────────────────────────────────────────╮ +│" game (https://store.steampowered.com/a│ +╰────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-40x1-softwrap.golden b/viewport/testdata/TestSizing/view-40x1-softwrap.golden new file mode 100644 index 000000000..327de0bda --- /dev/null +++ b/viewport/testdata/TestSizing/view-40x1-softwrap.golden @@ -0,0 +1,3 @@ +╭────────────────────────────────────────╮ +│57 Precepts of narcissistic comedy chara│ +╰────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-40x1.golden b/viewport/testdata/TestSizing/view-40x1.golden new file mode 100644 index 000000000..327de0bda --- /dev/null +++ b/viewport/testdata/TestSizing/view-40x1.golden @@ -0,0 +1,3 @@ +╭────────────────────────────────────────╮ +│57 Precepts of narcissistic comedy chara│ +╰────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-40x100percent.golden b/viewport/testdata/TestSizing/view-40x100percent.golden new file mode 100644 index 000000000..72b5294d0 --- /dev/null +++ b/viewport/testdata/TestSizing/view-40x100percent.golden @@ -0,0 +1,19 @@ +╭──────────────────────────────────────╮ +│57 Precepts of narcissistic comedy cha│ +│Precept One: 'Always Win Your Battles'│ +│ │ +│Precept Two: 'Never Let Them Laugh at │ +│Precept Three: 'Always Be Rested'. Fig│ +│Precept Four: 'Forget Your Past'. The │ +│Precept Five: 'Strength Beats Strength│ +│Precept Six: 'Choose Your Own Fate'. O│ +│Precept Seven: 'Mourn Not the Dead'. W│ +│Precept Eight: 'Travel Alone'. You can│ +│Precept Nine: 'Keep Your Home Tidy'. Y│ +│Precept Ten: 'Keep Your Weapon Sharp'.│ +│Precept Eleven: 'Mothers Will Always B│ +│Precept Twelve: 'Keep Your Cloak Dry'.│ +│Precept Thirteen: 'Never Be Afraid'. F│ +│Precept Fourteen: 'Respect Your Superi│ +│Precept Fifteen: 'One Foe, One Blow'. │ +╰──────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-content-lines.golden b/viewport/testdata/TestSizing/view-50x15-content-lines.golden new file mode 100644 index 000000000..236c8c1cc --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-content-lines.golden @@ -0,0 +1,15 @@ +57 Precepts of narcissistic comedy character Zote +awesome "Hollow knight" game + + + + + + + + + + + + + \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-at-bottom.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-at-bottom.golden new file mode 100644 index 000000000..6dd3f44b0 --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-at-bottom.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│Precept Thirteen: 'Never Be Afraid'. Fear can on│ +│ly hold you back. Facing your fears can be a tre│ +│mendous effort. Therefore, you should just not b│ +│e afraid in the first place. │ +│Precept Fourteen: 'Respect Your Superiors'. If s│ +│omeone is your superior in strength or intellect│ +│ or both, you need to show them your respect. Do│ +│n't ignore them or laugh at them. │ +│Precept Fifteen: 'One Foe, One Blow'. You should│ +│ only use a single blow to defeat an enemy. Any │ +│more is a waste. Also, by counting your blows as│ +│ you fight, you'll know how many foes you've def│ +│eated. │ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-at-top.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-at-top.golden new file mode 100644 index 000000000..7cc481a69 --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-at-top.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│57 Precepts of narcissistic comedy character Zot│ +│e from an awesome "Hollow knight" game (https://│ +│store.steampowered.com/app/367520/Hollow_Knight/│ +│). │ +│Precept One: 'Always Win Your Battles'. Losing a│ +│ battle earns you nothing and teaches you nothin│ +│g. Win your battles, or don't engage in them at │ +│all! │ +│ │ +│Precept Two: 'Never Let Them Laugh at You'. Fool│ +│s laugh at everything, even at their superiors. │ +│But beware, laughter isn't harmless! Laughter sp│ +│reads like a disease, and soon everyone is laugh│ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden new file mode 100644 index 000000000..cc1d5861f --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│ tremendous effort. Therefore, you should just│ +│ not be afraid in the first place. │ +│57 Precept Fourteen: 'Respect Your Superiors'. │ +│If │ +│ someone is your superior in strength or intel│ +│ lect or both, you need to show them your respe│ +│ ct. Don't ignore them or laugh at them. │ +│58 Precept Fifteen: 'One Foe, One Blow'. You │ +│shou │ +│ ld only use a single blow to defeat an enemy. │ +│ Any more is a waste. Also, by counting your bl│ +│ ows as you fight, you'll know how many foes yo│ +│ u've defeated. │ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden new file mode 100644 index 000000000..bbab5d919 --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│0 57 Precepts of narcissistic comedy character Z│ +│ ote from an awesome "Hollow knight" game (http│ +│ s://store.steampowered.com/app/367520/Hollow_K│ +│ night/). │ +│1 Precept One: 'Always Win Your Battles'. Losing│ +│ a battle earns you nothing and teaches you no│ +│ thing. Win your battles, or don't engage in th│ +│ em at all! │ +│ │ +│3 Precept Two: 'Never Let Them Laugh at You'. Fo│ +│ ols laugh at everything, even at their superio│ +│ rs. But beware, laughter isn't harmless! Laugh│ +│ ter spreads like a disease, and soon everyone │ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden new file mode 100644 index 000000000..ee747c6e0 --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│ ote from an awesome "Hollow knight" game (http│ +│ s://store.steampowered.com/app/367520/Hollow_K│ +│ night/). │ +│2 Precept One: 'Always Win Your Battles'. Losing│ +│ a battle earns you nothing and teaches you no│ +│ thing. Win your battles, or don't engage in th│ +│ em at all! │ +│ │ +│4 Precept Two: 'Never Let Them Laugh at You'. Fo│ +│ ols laugh at everything, even at their superio│ +│ rs. But beware, laughter isn't harmless! Laugh│ +│ ter spreads like a disease, and soon everyone │ +│ is laughing at you. You need to strike at the │ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden new file mode 100644 index 000000000..a08449bbe --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│ s://store.steampowered.com/app/367520/Hollow_K│ +│ night/). │ +│3 Precept One: 'Always Win Your Battles'. Losing│ +│ a battle earns you nothing and teaches you no│ +│ thing. Win your battles, or don't engage in th│ +│ em at all! │ +│ │ +│5 Precept Two: 'Never Let Them Laugh at You'. Fo│ +│ ols laugh at everything, even at their superio│ +│ rs. But beware, laughter isn't harmless! Laugh│ +│ ter spreads like a disease, and soon everyone │ +│ is laughing at you. You need to strike at the │ +│ source of this perverse merriment quickly to s│ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-1.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-1.golden new file mode 100644 index 000000000..5ce9bf6d3 --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-1.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│e from an awesome "Hollow knight" game (https://│ +│store.steampowered.com/app/367520/Hollow_Knight/│ +│). │ +│Precept One: 'Always Win Your Battles'. Losing a│ +│ battle earns you nothing and teaches you nothin│ +│g. Win your battles, or don't engage in them at │ +│all! │ +│ │ +│Precept Two: 'Never Let Them Laugh at You'. Fool│ +│s laugh at everything, even at their superiors. │ +│But beware, laughter isn't harmless! Laughter sp│ +│reads like a disease, and soon everyone is laugh│ +│ing at you. You need to strike at the source of │ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-2.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-2.golden new file mode 100644 index 000000000..7d5f750bb --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-scrolled-plus-2.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│store.steampowered.com/app/367520/Hollow_Knight/│ +│). │ +│Precept One: 'Always Win Your Battles'. Losing a│ +│ battle earns you nothing and teaches you nothin│ +│g. Win your battles, or don't engage in them at │ +│all! │ +│ │ +│Precept Two: 'Never Let Them Laugh at You'. Fool│ +│s laugh at everything, even at their superiors. │ +│But beware, laughter isn't harmless! Laughter sp│ +│reads like a disease, and soon everyone is laugh│ +│ing at you. You need to strike at the source of │ +│this perverse merriment quickly to stop it from │ +╰────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/viewport/viewport.go b/viewport/viewport.go index 624e2912b..539f93b32 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -1,7 +1,9 @@ package viewport import ( + "cmp" "math" + "slices" "strings" "github.com/charmbracelet/bubbles/v2/key" @@ -87,7 +89,9 @@ type Model struct { // 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. + // show statuses, etc. It is expected that the real-width (as measured by + // [lipgloss.Width]) of the returned value is always consistent, regardless + // of index, soft wrapping, etc. LeftGutterFunc GutterFunc initialized bool @@ -131,9 +135,14 @@ var NoGutter = func(GutterContext) string { return "" } // GutterContext provides context to a [GutterFunc]. type GutterContext struct { - Index int + // Index is the line index of the line which the gutter is being rendered for. + Index int + + // TotalLines is the total number of lines in the viewport. TotalLines int - Soft bool + + // Soft is whether or not the line is soft wrapped. + Soft bool } func (m *Model) setInitialValues() { @@ -189,15 +198,15 @@ func (m Model) PastBottom() bool { // ScrollPercent returns the amount scrolled as a float between 0 and 1. func (m Model) ScrollPercent() float64 { - count := m.lineCount() - if m.Height() >= count { + total, _, _ := m.calculateLine(0) + if m.Height() >= total { return 1.0 } y := float64(m.YOffset()) h := float64(m.Height()) - t := float64(count) + t := float64(total) v := y / (t - h) - return math.Max(0.0, math.Min(1.0, v)) + return clamp(v, 0, 1) } // HorizontalScrollPercent returns the amount horizontally scrolled as a float @@ -210,7 +219,7 @@ func (m Model) HorizontalScrollPercent() float64 { h := float64(m.Width()) t := float64(m.longestLineWidth) v := y / (t - h) - return math.Max(0.0, math.Min(1.0, v)) + return clamp(v, 0, 1) } // SetContent set the pager's text content. @@ -221,15 +230,26 @@ func (m *Model) SetContent(s string) { } // 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]. +// If a given line has a \n in it, it will still be split into multiple lines +// similar to that of [Model.SetContent]. 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 + } else { + // iterate in reverse, so we can safely modify the slice. + for i := len(m.lines) - 1; i >= 0; i-- { + m.lines[i] = strings.ReplaceAll(m.lines[i], "\r\n", "\n") + + if j := strings.Index(m.lines[i], "\n"); j != -1 { + m.lines = slices.Insert(m.lines, i+1, m.lines[i][j+1:]) // append after current index. + m.lines[i] = m.lines[i][:j] // keep the part before the \n. + } + } } + m.longestLineWidth = maxLineWidth(m.lines) m.ClearHighlights() @@ -244,59 +264,100 @@ 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) { +// calculateLine taking soft wrapping into account, returns the total viewable +// lines and the real-line index for the given yoffset, as well as the virtual +// line offset. +func (m Model) calculateLine(yoffset int) (total, ridx, voffset int) { + var lineHeight int + if !m.SoftWrap { for i, line := range m.lines { - adjust := max(1, lipgloss.Height(line)) - if yoffset >= total && yoffset < total+adjust { - idx = i + lineHeight = max(1, lipgloss.Height(line)) + if yoffset >= total && yoffset < total+lineHeight { + ridx = i } - total += adjust + total += lineHeight } if yoffset >= total { - idx = len(m.lines) + ridx = len(m.lines) } - return total, idx + return total, ridx, 0 } - maxWidth := m.maxWidth() - var gutterSize int + var gutterSize float64 if m.LeftGutterFunc != nil { - gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + gutterSize = float64(ansi.StringWidth(m.LeftGutterFunc(GutterContext{}))) } + + maxHeight := m.maxHeight() + maxWidth := max(0, float64(m.maxWidth())-gutterSize) + + // voffsets tracks the total size of wrapped lines AFTER the real index, which we can + // use to know where the virtual offset is relative to the yoffset. + voffsets := make([]int, 0, maxHeight) + offsetSize := func() (size int) { + for _, v := range voffsets { + size += v + } + return size + } + + // yoffsetRelative is the total size of wrapped lines BEFORE the real index, + // which we can use to know where the virtual offset is relative to the yoffset. + var yoffsetRelative int + var foundVOffset bool + for i, line := range m.lines { - adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize)) - if yoffset >= total && yoffset < total+adjust { - idx = i + lineHeight = max(1, int(math.Ceil(float64(ansi.StringWidth(line))/maxWidth))) + + if yoffset >= total && yoffset < total+lineHeight { + ridx = i + + // Only track relative offsets before we find the real index. + yoffsetRelative = total - yoffset + lineHeight + + if lineHeight == 1 { + yoffsetRelative-- + } + } + + total += lineHeight + + // Haven't found the real index yet, or we're done finding the virtual offset. + if yoffsetRelative == 0 || foundVOffset { + continue + } + + // If we've found the real index, begin to calculate the virtual offset. + voffsets = append(voffsets, lineHeight) + for offsetSize() > maxHeight && len(voffsets) > 0 { + // Ignore the previous real index, and move the virtual offset forward by 1 ridx. + voffsets = voffsets[1:] + if len(voffsets) > 0 { + // It's large enough that we need to take into account potentially multiple lines. + voffset = max(0, voffsets[0]-yoffsetRelative) + } else if maxHeight <= lineHeight { + // The viewport height is the same, or smaller size than the line height. + voffset = max(0, lineHeight-yoffsetRelative) + } + + foundVOffset = true } - total += adjust } + if yoffset >= total { - idx = len(m.lines) + ridx = len(m.lines) + voffset = max(ridx, total-ridx) } - 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 + return total, ridx, voffset } // 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()) + total, _, _ := m.calculateLine(0) + return max(0, total-m.Height()+m.Style.GetVerticalFrameSize()) } // maxXOffset returns the maximum possible value of the x-offset based on the @@ -308,15 +369,13 @@ func (m Model) maxXOffset() int { func (m Model) maxWidth() int { var gutterSize int if m.LeftGutterFunc != nil { - gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{})) + gutterSize = ansi.StringWidth(m.LeftGutterFunc(GutterContext{})) } - return m.Width() - - m.Style.GetHorizontalFrameSize() - - gutterSize + return max(0, m.Width()-m.Style.GetHorizontalFrameSize()-gutterSize) } func (m Model) maxHeight() int { - return m.Height() - m.Style.GetVerticalFrameSize() + return max(0, m.Height()-m.Style.GetVerticalFrameSize()) } // visibleLines returns the lines that should currently be visible in the @@ -325,14 +384,15 @@ 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) + if maxHeight == 0 || maxWidth == 0 { + return nil + } + + total, ridx, voffset := m.calculateLine(m.YOffset()) + if total > 0 { + bottom := clamp(ridx+maxHeight, ridx, len(m.lines)) + lines = m.styleLines(m.lines[ridx:bottom], ridx) + lines = m.highlightLines(lines, ridx) } for m.FillHeight && len(lines) < maxHeight { @@ -341,21 +401,18 @@ func (m Model) visibleLines() (lines []string) { // 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) + return m.setupGutter(lines, total, ridx) } if m.SoftWrap { - return m.softWrap(lines, maxWidth) + return m.softWrap(lines, maxWidth, maxHeight, total, voffset) } - 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") + // Cut the lines to the viewport width. + for i := range lines { + lines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth) } - return m.setupGutter(lines) + return m.setupGutter(lines, total, ridx) } // styleLines styles the lines using [Model.StyleLineFunc]. @@ -397,13 +454,24 @@ func (m Model) highlightLines(lines []string, offset int) []string { return lines } -func (m Model) softWrap(lines []string, maxWidth int) []string { - var wrappedLines []string - total := m.TotalLineCount() +func (m Model) softWrap(lines []string, maxWidth, maxHeight, total, voffset int) []string { + wrappedLines := make([]string, 0, maxHeight) + + var idx, lineWidth int + var truncatedLine string + for i, line := range lines { - idx := 0 - for ansi.StringWidth(line) >= idx { - truncatedLine := ansi.Cut(line, idx, maxWidth+idx) + // If the line is less than or equal to the max width, it can be added + // as is. + lineWidth = ansi.StringWidth(line) + if lineWidth <= maxWidth { + wrappedLines = append(wrappedLines, line) + continue + } + + idx = 0 + for lineWidth > idx { + truncatedLine = ansi.Cut(line, idx, maxWidth+idx) if m.LeftGutterFunc != nil { truncatedLine = m.LeftGutterFunc(GutterContext{ Index: i + m.YOffset(), @@ -415,20 +483,21 @@ func (m Model) softWrap(lines []string, maxWidth int) []string { idx += maxWidth } } - return wrappedLines + + return wrappedLines[voffset:min(voffset+maxHeight, len(wrappedLines))] } -// setupGutter sets up the left gutter using [Moddel.LeftGutterFunc]. -func (m Model) setupGutter(lines []string) []string { +// setupGutter sets up the left gutter using [Model.LeftGutterFunc]. +func (m Model) setupGutter(lines []string, total, ridx int) []string { if m.LeftGutterFunc == nil { return lines } - offset := max(0, m.lineToIndex(m.YOffset())) - total := m.TotalLineCount() + offset := max(0, ridx) result := make([]string, len(lines)) + var line []string for i := range lines { - var line []string + line = line[:0] for j, realLine := range strings.Split(lines[i], "\n") { line = append(line, m.LeftGutterFunc(GutterContext{ Index: i + offset, @@ -461,8 +530,6 @@ func (m *Model) EnsureVisible(line, colstart, colend int) { if line < m.YOffset() || line >= m.YOffset()+m.maxHeight() { m.SetYOffset(line) } - - m.visibleLines() } // PageDown moves the view down by the number of lines in the viewport. @@ -470,7 +537,6 @@ func (m *Model) PageDown() { if m.AtBottom() { return } - m.ScrollDown(m.Height()) } @@ -479,7 +545,6 @@ func (m *Model) PageUp() { if m.AtTop() { return } - m.ScrollUp(m.Height()) } @@ -488,7 +553,6 @@ func (m *Model) HalfPageDown() { if m.AtBottom() { return } - m.ScrollDown(m.Height() / 2) //nolint:mnd } @@ -497,7 +561,6 @@ func (m *Model) HalfPageUp() { if m.AtTop() { return } - m.ScrollUp(m.Height() / 2) //nolint:mnd } @@ -506,25 +569,22 @@ func (m *Model) ScrollDown(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.hiIdx = m.findNearestMatch() } -// ScrollUp moves the view up by the given number of lines. Returns the new -// lines to show. +// ScrollUp moves the view up by the given number of lines. func (m *Model) ScrollUp(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.hiIdx = m.findNearestMatch() } // SetHorizontalStep sets the amount of cells that the viewport moves in the @@ -558,7 +618,8 @@ func (m *Model) ScrollRight(n int) { // TotalLineCount returns the total number of lines (both hidden and visible) within the viewport. func (m Model) TotalLineCount() int { - return m.lineCount() + total, _, _ := m.calculateLine(0) + return total } // VisibleLineCount returns the number of the visible lines within the viewport. @@ -571,16 +632,15 @@ func (m *Model) GotoTop() (lines []string) { if m.AtTop() { return nil } - m.SetYOffset(0) - m.hiIdx = m.findNearedtMatch() + m.hiIdx = m.findNearestMatch() 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.hiIdx = m.findNearestMatch() return m.visibleLines() } @@ -597,7 +657,7 @@ func (m *Model) SetHighlights(matches [][]int) { return } m.highlights = parseMatches(m.GetContent(), matches) - m.hiIdx = m.findNearedtMatch() + m.hiIdx = m.findNearestMatch() m.showHighlight() } @@ -620,7 +680,6 @@ func (m *Model) HighlightNext() { if m.highlights == nil { return } - m.hiIdx = (m.hiIdx + 1) % len(m.highlights) m.showHighlight() } @@ -630,12 +689,11 @@ 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 { +func (m Model) findNearestMatch() int { for i, match := range m.highlights { if match.lineStart >= m.YOffset() { return i @@ -725,20 +783,23 @@ func (m Model) View() string { if sh := m.Style.GetHeight(); sh != 0 { h = min(h, sh) } + + if w == 0 || h == 0 { + return "" + } + contentWidth := w - m.Style.GetHorizontalFrameSize() contentHeight := h - m.Style.GetVerticalFrameSize() 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. + Width(contentWidth). // pad to width. + Height(contentHeight). // pad to height. Render(strings.Join(m.visibleLines(), "\n")) return m.Style. UnsetWidth().UnsetHeight(). // Style size already applied in contents. Render(contents) } -func clamp(v, low, high int) int { +func clamp[T cmp.Ordered](v, low, high T) T { if high < low { low, high = high, low } @@ -748,7 +809,7 @@ func clamp(v, low, high int) int { func maxLineWidth(lines []string) int { result := 0 for _, line := range lines { - result = max(result, lipgloss.Width(line)) + result = max(result, ansi.StringWidth(line)) } return result } diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index bf504ddd4..ec6b8b7e1 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -1,14 +1,53 @@ package viewport import ( + "fmt" "reflect" "regexp" "strings" "testing" + "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/exp/golden" ) +type suffixedTest struct { + testing.TB + suffix string +} + +func (s *suffixedTest) Name() string { + return fmt.Sprintf("%s-%s", s.TB.Name(), s.suffix) +} + +// withSuffix is a helper to add a temporary suffix to the test name. Primarily +// useful for golden tests since there is currently no way to have multiple snapshots +// in the same test. +func withSuffix(t testing.TB, suffix string) testing.TB { + t.Helper() + + return &suffixedTest{TB: t, suffix: suffix} +} + +const textContentList = `57 Precepts of narcissistic comedy character Zote from an awesome "Hollow knight" game (https://store.steampowered.com/app/367520/Hollow_Knight/). +Precept One: 'Always Win Your Battles'. Losing a battle earns you nothing and teaches you nothing. Win your battles, or don't engage in them at all! + +Precept Two: 'Never Let Them Laugh at You'. Fools laugh at everything, even at their superiors. But beware, laughter isn't harmless! Laughter spreads like a disease, and soon everyone is laughing at you. You need to strike at the source of this perverse merriment quickly to stop it from spreading. +Precept Three: 'Always Be Rested'. Fighting and adventuring take their toll on your body. When you rest, your body strengthens and repairs itself. The longer you rest, the stronger you become. +Precept Four: 'Forget Your Past'. The past is painful, and thinking about your past can only bring you misery. Think about something else instead, such as the future, or some food. +Precept Five: 'Strength Beats Strength'. Is your opponent strong? No matter! Simply overcome their strength with even more strength, and they'll soon be defeated. +Precept Six: 'Choose Your Own Fate'. Our elders teach that our fate is chosen for us before we are even born. I disagree. +Precept Seven: 'Mourn Not the Dead'. When we die, do things get better for us or worse? There's no way to tell, so we shouldn't bother mourning. Or celebrating for that matter. +Precept Eight: 'Travel Alone'. You can rely on nobody, and nobody will always be loyal. Therefore, nobody should be your constant companion. +Precept Nine: 'Keep Your Home Tidy'. Your home is where you keep your most prized possession - yourself. Therefore, you should make an effort to keep it nice and clean. +Precept Ten: 'Keep Your Weapon Sharp'. I make sure that my weapon, 'Life Ender', is kept well-sharpened at all times. This makes it much easier to cut things. +Precept Eleven: 'Mothers Will Always Betray You'. This Precept explains itself. +Precept Twelve: 'Keep Your Cloak Dry'. If your cloak gets wet, dry it as soon as you can. Wearing wet cloaks is unpleasant, and can lead to illness. +Precept Thirteen: 'Never Be Afraid'. Fear can only hold you back. Facing your fears can be a tremendous effort. Therefore, you should just not be afraid in the first place. +Precept Fourteen: 'Respect Your Superiors'. If someone is your superior in strength or intellect or both, you need to show them your respect. Don't ignore them or laugh at them. +Precept Fifteen: 'One Foe, One Blow'. You should only use a single blow to defeat an enemy. Any more is a waste. Also, by counting your blows as you fight, you'll know how many foes you've defeated.` + func TestNew(t *testing.T) { t.Parallel() @@ -165,25 +204,7 @@ func TestResetIndent(t *testing.T) { func TestVisibleLines(t *testing.T) { t.Parallel() - defaultList := []string{ - `57 Precepts of narcissistic comedy character Zote from an awesome "Hollow knight" game (https://store.steampowered.com/app/367520/Hollow_Knight/).`, - `Precept One: 'Always Win Your Battles'. Losing a battle earns you nothing and teaches you nothing. Win your battles, or don't engage in them at all!`, - `Precept Two: 'Never Let Them Laugh at You'. Fools laugh at everything, even at their superiors. But beware, laughter isn't harmless! Laughter spreads like a disease, and soon everyone is laughing at you. You need to strike at the source of this perverse merriment quickly to stop it from spreading.`, - `Precept Three: 'Always Be Rested'. Fighting and adventuring take their toll on your body. When you rest, your body strengthens and repairs itself. The longer you rest, the stronger you become.`, - `Precept Four: 'Forget Your Past'. The past is painful, and thinking about your past can only bring you misery. Think about something else instead, such as the future, or some food.`, - `Precept Five: 'Strength Beats Strength'. Is your opponent strong? No matter! Simply overcome their strength with even more strength, and they'll soon be defeated.`, - `Precept Six: 'Choose Your Own Fate'. Our elders teach that our fate is chosen for us before we are even born. I disagree.`, - `Precept Seven: 'Mourn Not the Dead'. When we die, do things get better for us or worse? There's no way to tell, so we shouldn't bother mourning. Or celebrating for that matter.`, - `Precept Eight: 'Travel Alone'. You can rely on nobody, and nobody will always be loyal. Therefore, nobody should be your constant companion.`, - `Precept Nine: 'Keep Your Home Tidy'. Your home is where you keep your most prized possession - yourself. Therefore, you should make an effort to keep it nice and clean.`, - `Precept Ten: 'Keep Your Weapon Sharp'. I make sure that my weapon, 'Life Ender', is kept well-sharpened at all times. This makes it much easier to cut things.`, - `Precept Eleven: 'Mothers Will Always Betray You'. This Precept explains itself.`, - `Precept Twelve: 'Keep Your Cloak Dry'. If your cloak gets wet, dry it as soon as you can. Wearing wet cloaks is unpleasant, and can lead to illness.`, - `Precept Thirteen: 'Never Be Afraid'. Fear can only hold you back. Facing your fears can be a tremendous effort. Therefore, you should just not be afraid in the first place.`, - `Precept Fourteen: 'Respect Your Superiors'. If someone is your superior in strength or intellect or both, you need to show them your respect. Don't ignore them or laugh at them.`, - `Precept Fifteen: 'One Foe, One Blow'. You should only use a single blow to defeat an enemy. Any more is a waste. Also, by counting your blows as you fight, you'll know how many foes you've defeated.`, - `...`, - } + defaultList := strings.Split(textContentList, "\n") t.Run("empty list", func(t *testing.T) { t.Parallel() @@ -287,7 +308,7 @@ func TestVisibleLines(t *testing.T) { t.Errorf("first list item has to have prefix %s, get %s", newPrefix, list[0]) } - if list[lastItem] != "..." { + if list[lastItem] != defaultList[defaultLastItem] { t.Errorf("last item should be empty, got %s", list[lastItem]) } @@ -564,32 +585,186 @@ func testHighlights(tb testing.TB, content string, re *regexp.Regexp, expect []h } } -func TestCalculateLine(t *testing.T) { - t.Run("simple", func(t *testing.T) { - vp := New(WithWidth(40), WithHeight(20)) - vp.SetContent("foo\nbar") - total, idx := vp.calculateLine(0) - if total != 2 || idx != 0 { - t.Errorf("total: %d, idx: %d", total, idx) +func TestSizing(t *testing.T) { + t.Parallel() + + lines := strings.Split(textContentList, "\n") + + t.Run("view-40x100percent", func(t *testing.T) { + t.Parallel() + + width := 40 + height := len(lines) + 2 // +2 for border. + + vt := New(WithWidth(width), WithHeight(height)) + vt.Style = vt.Style.Border(lipgloss.RoundedBorder()) + vt.SetContent(textContentList) + + view := vt.View() + if w, h := lipgloss.Size(view); w != width || h != height { + t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) + } + + golden.RequireEqual(t, view) + }) + + t.Run("view-50x15-softwrap", func(t *testing.T) { + t.Parallel() + + width := 50 + height := 15 + + vt := New(WithWidth(width), WithHeight(height)) + vt.SoftWrap = true + vt.Style = vt.Style.Border(lipgloss.RoundedBorder()) + vt.SetContent(textContentList) + + view := vt.View() + if w, h := lipgloss.Size(view); w != width || h != height { + t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) + } + + golden.RequireEqual(withSuffix(t, "at-top"), vt.View()) + + vt.ScrollDown(1) + golden.RequireEqual(withSuffix(t, "scrolled-plus-1"), vt.View()) + + vt.ScrollDown(1) + golden.RequireEqual(withSuffix(t, "scrolled-plus-2"), vt.View()) + + vt.GotoBottom() + golden.RequireEqual(withSuffix(t, "at-bottom"), vt.View()) + }) + + t.Run("view-50x15-softwrap-gutter", func(t *testing.T) { + t.Parallel() + + width := 50 + height := 15 + + vt := New(WithWidth(width), WithHeight(height)) + vt.SoftWrap = true + vt.Style = vt.Style.Border(lipgloss.RoundedBorder()) + vt.LeftGutterFunc = func(ctx GutterContext) string { + if ctx.Soft { + return " " + } + return fmt.Sprintf("%d ", ctx.Index) + } + vt.SetContent(textContentList) + + view := vt.View() + if w, h := lipgloss.Size(view); w != width || h != height { + t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) + } + + golden.RequireEqual(withSuffix(t, "at-top"), vt.View()) + + vt.ScrollDown(1) + golden.RequireEqual(withSuffix(t, "scrolled-plus-1"), vt.View()) + + vt.ScrollDown(1) + golden.RequireEqual(withSuffix(t, "scrolled-plus-2"), vt.View()) + + vt.GotoBottom() + golden.RequireEqual(withSuffix(t, "at-bottom"), vt.View()) + }) + + t.Run("view-40x1-softwrap", func(t *testing.T) { + t.Parallel() + + width := 40 + 2 // +2 for border. + height := 1 + 2 // +2 for border. + + vt := New(WithWidth(width), WithHeight(height)) + vt.SoftWrap = true + vt.Style = vt.Style.Border(lipgloss.RoundedBorder()) + vt.SetContent(textContentList) + + view := vt.View() + if w, h := lipgloss.Size(view); w != width || h != height { + t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) + } + + golden.RequireEqual(t, view) + + vt.ScrollDown(1) + golden.RequireEqual(withSuffix(t, "scrolled-plus-1"), vt.View()) + + vt.ScrollDown(1) + golden.RequireEqual(withSuffix(t, "scrolled-plus-2"), vt.View()) + + vt.GotoBottom() + golden.RequireEqual(withSuffix(t, "at-bottom"), vt.View()) + }) + + t.Run("view-50x15-content-lines", func(t *testing.T) { + t.Parallel() + + content := []string{ + "57 Precepts of narcissistic comedy character Zote from an\nawesome \"Hollow knight\" game", + } + vt := New(WithWidth(50), WithHeight(15)) + vt.SetContentLines(content) + golden.RequireEqual(t, vt.View()) + }) + + t.Run("view-0x0", func(t *testing.T) { + t.Parallel() + vt := New(WithWidth(0), WithHeight(0)) + vt.SetContent(textContentList) + _ = vt.View() // ensure no panic. + }) + t.Run("view-1x0", func(t *testing.T) { + t.Parallel() + vt := New(WithWidth(1), WithHeight(0)) + vt.SetContent(textContentList) + _ = vt.View() // ensure no panic. + }) + t.Run("view-0x1", func(t *testing.T) { + t.Parallel() + vt := New(WithWidth(0), WithHeight(1)) + vt.SetContent(textContentList) + _ = vt.View() // ensure no panic. + }) +} + +func BenchmarkView(b *testing.B) { + b.Run("view-30x15", func(b *testing.B) { + vt := New(WithWidth(30), WithHeight(15)) + vt.SetContent(textContentList) + + for i := 0; i < b.N; i++ { + vt.View() } }) - t.Run("line breaks", func(t *testing.T) { - vp := New(WithWidth(40), WithHeight(20)) - vp.SetContentLines([]string{"new\nbar", "foo", "another line", "multiple\nlines"}) - total, idx := vp.calculateLine(6) - if total != 6 || idx != 4 { - t.Errorf("total: %d, idx: %d", total, idx) + b.Run("view-100x100", func(b *testing.B) { + vt := New(WithWidth(100), WithHeight(100)) + vt.SetContent(textContentList) + + for i := 0; i < b.N; i++ { + vt.View() } }) - t.Run("soft breaks", func(t *testing.T) { - vp := New(WithWidth(40), WithHeight(20)) - vp.SoftWrap = true - vp.SetContent("super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super long line super\nlong line super long line super long line super long line") - total, idx := vp.calculateLine(10) - if total != 6 || idx != 2 { - t.Errorf("total: %d, idx: %d", total, idx) + b.Run("view-30x15-softwrap", func(b *testing.B) { + vt := New(WithWidth(30), WithHeight(15)) + vt.SoftWrap = true + vt.SetContent(textContentList) + + for i := 0; i < b.N; i++ { + vt.View() + } + }) + + b.Run("view-100x100-softwrap", func(b *testing.B) { + vt := New(WithWidth(100), WithHeight(100)) + vt.SoftWrap = true + vt.SetContent(textContentList) + + for i := 0; i < b.N; i++ { + vt.View() } }) } From b4884c2f81fc43217d0bc99d603cbce80b03eb2a Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Sat, 9 Aug 2025 03:14:47 -0600 Subject: [PATCH 2/4] fix(viewport): more fixes, addl. perf improvements Signed-off-by: Liam Stanley --- ...iew-50x15-softwrap-gutter-at-bottom.golden | 8 +- .../view-50x15-softwrap-gutter-at-top.golden | 6 +- ...x15-softwrap-gutter-scrolled-plus-1.golden | 4 +- ...x15-softwrap-gutter-scrolled-plus-2.golden | 4 +- viewport/viewport.go | 121 +++++------------- viewport/viewport_test.go | 17 ++- 6 files changed, 56 insertions(+), 104 deletions(-) diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden index cc1d5861f..af97b534b 100644 --- a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden @@ -1,13 +1,13 @@ ╭────────────────────────────────────────────────╮ +│ Precept Thirteen: 'Never Be Afraid'. Fear can │ +│ only hold you back. Facing your fears can be a│ │ tremendous effort. Therefore, you should just│ │ not be afraid in the first place. │ -│57 Precept Fourteen: 'Respect Your Superiors'. │ -│If │ +│ Precept Fourteen: 'Respect Your Superiors'. If│ │ someone is your superior in strength or intel│ │ lect or both, you need to show them your respe│ │ ct. Don't ignore them or laugh at them. │ -│58 Precept Fifteen: 'One Foe, One Blow'. You │ -│shou │ +│ Precept Fifteen: 'One Foe, One Blow'. You shou│ │ ld only use a single blow to defeat an enemy. │ │ Any more is a waste. Also, by counting your bl│ │ ows as you fight, you'll know how many foes yo│ diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden index bbab5d919..28b808d1c 100644 --- a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden @@ -1,14 +1,14 @@ ╭────────────────────────────────────────────────╮ -│0 57 Precepts of narcissistic comedy character Z│ +│ 57 Precepts of narcissistic comedy character Z│ │ ote from an awesome "Hollow knight" game (http│ │ s://store.steampowered.com/app/367520/Hollow_K│ │ night/). │ -│1 Precept One: 'Always Win Your Battles'. Losing│ +│ Precept One: 'Always Win Your Battles'. Losing│ │ a battle earns you nothing and teaches you no│ │ thing. Win your battles, or don't engage in th│ │ em at all! │ │ │ -│3 Precept Two: 'Never Let Them Laugh at You'. Fo│ +│ Precept Two: 'Never Let Them Laugh at You'. Fo│ │ ols laugh at everything, even at their superio│ │ rs. But beware, laughter isn't harmless! Laugh│ │ ter spreads like a disease, and soon everyone │ diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden index ee747c6e0..cf1f12fd7 100644 --- a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-1.golden @@ -2,12 +2,12 @@ │ ote from an awesome "Hollow knight" game (http│ │ s://store.steampowered.com/app/367520/Hollow_K│ │ night/). │ -│2 Precept One: 'Always Win Your Battles'. Losing│ +│ Precept One: 'Always Win Your Battles'. Losing│ │ a battle earns you nothing and teaches you no│ │ thing. Win your battles, or don't engage in th│ │ em at all! │ │ │ -│4 Precept Two: 'Never Let Them Laugh at You'. Fo│ +│ Precept Two: 'Never Let Them Laugh at You'. Fo│ │ ols laugh at everything, even at their superio│ │ rs. But beware, laughter isn't harmless! Laugh│ │ ter spreads like a disease, and soon everyone │ diff --git a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden index a08449bbe..131dca048 100644 --- a/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-scrolled-plus-2.golden @@ -1,12 +1,12 @@ ╭────────────────────────────────────────────────╮ │ s://store.steampowered.com/app/367520/Hollow_K│ │ night/). │ -│3 Precept One: 'Always Win Your Battles'. Losing│ +│ Precept One: 'Always Win Your Battles'. Losing│ │ a battle earns you nothing and teaches you no│ │ thing. Win your battles, or don't engage in th│ │ em at all! │ │ │ -│5 Precept Two: 'Never Let Them Laugh at You'. Fo│ +│ Precept Two: 'Never Let Them Laugh at You'. Fo│ │ ols laugh at everything, even at their superio│ │ rs. But beware, laughter isn't harmless! Laugh│ │ ter spreads like a disease, and soon everyone │ diff --git a/viewport/viewport.go b/viewport/viewport.go index 539f93b32..9c972e04d 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -222,10 +222,8 @@ func (m Model) HorizontalScrollPercent() float64 { return clamp(v, 0, 1) } -// SetContent set the pager's text content. -// Line endings will be normalized to '\n'. +// 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")) } @@ -240,12 +238,14 @@ func (m *Model) SetContentLines(lines []string) { m.lines = nil } else { // iterate in reverse, so we can safely modify the slice. + var subLines []string for i := len(m.lines) - 1; i >= 0; i-- { - m.lines[i] = strings.ReplaceAll(m.lines[i], "\r\n", "\n") + m.lines[i] = strings.ReplaceAll(m.lines[i], "\r\n", "\n") // normalize line endings - if j := strings.Index(m.lines[i], "\n"); j != -1 { - m.lines = slices.Insert(m.lines, i+1, m.lines[i][j+1:]) // append after current index. - m.lines[i] = m.lines[i][:j] // keep the part before the \n. + subLines = strings.Split(m.lines[i], "\n") + if len(subLines) > 1 { + m.lines = slices.Insert(m.lines, i+1, subLines[1:]...) + m.lines[i] = subLines[0] } } } @@ -268,86 +268,28 @@ func (m Model) GetContent() string { // lines and the real-line index for the given yoffset, as well as the virtual // line offset. func (m Model) calculateLine(yoffset int) (total, ridx, voffset int) { - var lineHeight int - if !m.SoftWrap { - for i, line := range m.lines { - lineHeight = max(1, lipgloss.Height(line)) - if yoffset >= total && yoffset < total+lineHeight { - ridx = i - } - total += lineHeight - } - if yoffset >= total { - ridx = len(m.lines) - } + total = len(m.lines) + ridx = min(yoffset, len(m.lines)) return total, ridx, 0 } - var gutterSize float64 - if m.LeftGutterFunc != nil { - gutterSize = float64(ansi.StringWidth(m.LeftGutterFunc(GutterContext{}))) - } - - maxHeight := m.maxHeight() - maxWidth := max(0, float64(m.maxWidth())-gutterSize) - - // voffsets tracks the total size of wrapped lines AFTER the real index, which we can - // use to know where the virtual offset is relative to the yoffset. - voffsets := make([]int, 0, maxHeight) - offsetSize := func() (size int) { - for _, v := range voffsets { - size += v - } - return size - } - - // yoffsetRelative is the total size of wrapped lines BEFORE the real index, - // which we can use to know where the virtual offset is relative to the yoffset. - var yoffsetRelative int - var foundVOffset bool + maxWidth := float64(m.maxWidth()) + var lineHeight int for i, line := range m.lines { lineHeight = max(1, int(math.Ceil(float64(ansi.StringWidth(line))/maxWidth))) if yoffset >= total && yoffset < total+lineHeight { ridx = i - - // Only track relative offsets before we find the real index. - yoffsetRelative = total - yoffset + lineHeight - - if lineHeight == 1 { - yoffsetRelative-- - } + voffset = yoffset - total } - total += lineHeight - - // Haven't found the real index yet, or we're done finding the virtual offset. - if yoffsetRelative == 0 || foundVOffset { - continue - } - - // If we've found the real index, begin to calculate the virtual offset. - voffsets = append(voffsets, lineHeight) - for offsetSize() > maxHeight && len(voffsets) > 0 { - // Ignore the previous real index, and move the virtual offset forward by 1 ridx. - voffsets = voffsets[1:] - if len(voffsets) > 0 { - // It's large enough that we need to take into account potentially multiple lines. - voffset = max(0, voffsets[0]-yoffsetRelative) - } else if maxHeight <= lineHeight { - // The viewport height is the same, or smaller size than the line height. - voffset = max(0, lineHeight-yoffsetRelative) - } - - foundVOffset = true - } } if yoffset >= total { ridx = len(m.lines) - voffset = max(ridx, total-ridx) + voffset = 0 } return total, ridx, voffset @@ -366,6 +308,8 @@ func (m Model) maxXOffset() int { return max(0, m.longestLineWidth-m.Width()) } +// maxWidth returns the maximum width of the viewport. It accounts for the frame +// size, in addition to the gutter size. func (m Model) maxWidth() int { var gutterSize int if m.LeftGutterFunc != nil { @@ -374,6 +318,8 @@ func (m Model) maxWidth() int { return max(0, m.Width()-m.Style.GetHorizontalFrameSize()-gutterSize) } +// maxHeight returns the maximum height of the viewport. It accounts for the frame +// size. func (m Model) maxHeight() int { return max(0, m.Height()-m.Style.GetVerticalFrameSize()) } @@ -405,7 +351,7 @@ func (m Model) visibleLines() (lines []string) { } if m.SoftWrap { - return m.softWrap(lines, maxWidth, maxHeight, total, voffset) + return m.softWrap(lines, maxWidth, maxHeight, total, ridx, voffset) } // Cut the lines to the viewport width. @@ -454,7 +400,7 @@ func (m Model) highlightLines(lines []string, offset int) []string { return lines } -func (m Model) softWrap(lines []string, maxWidth, maxHeight, total, voffset int) []string { +func (m Model) softWrap(lines []string, maxWidth, maxHeight, total, ridx, voffset int) []string { wrappedLines := make([]string, 0, maxHeight) var idx, lineWidth int @@ -464,7 +410,15 @@ func (m Model) softWrap(lines []string, maxWidth, maxHeight, total, voffset int) // If the line is less than or equal to the max width, it can be added // as is. lineWidth = ansi.StringWidth(line) + if lineWidth <= maxWidth { + if m.LeftGutterFunc != nil { + line = m.LeftGutterFunc(GutterContext{ + Index: i + ridx, + TotalLines: total, + Soft: false, + }) + line + } wrappedLines = append(wrappedLines, line) continue } @@ -474,7 +428,7 @@ func (m Model) softWrap(lines []string, maxWidth, maxHeight, total, voffset int) truncatedLine = ansi.Cut(line, idx, maxWidth+idx) if m.LeftGutterFunc != nil { truncatedLine = m.LeftGutterFunc(GutterContext{ - Index: i + m.YOffset(), + Index: i + ridx, TotalLines: total, Soft: idx > 0, }) + truncatedLine @@ -493,21 +447,14 @@ func (m Model) setupGutter(lines []string, total, ridx int) []string { return lines } - offset := max(0, ridx) - result := make([]string, len(lines)) - var line []string for i := range lines { - line = line[:0] - 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") + lines[i] = m.LeftGutterFunc(GutterContext{ + Index: i + ridx, + TotalLines: total, + Soft: false, + }) + lines[i] } - return result + return lines } // SetYOffset sets the Y offset. diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go index ec6b8b7e1..7af40a4cd 100644 --- a/viewport/viewport_test.go +++ b/viewport/viewport_test.go @@ -646,27 +646,32 @@ func TestSizing(t *testing.T) { vt.SoftWrap = true vt.Style = vt.Style.Border(lipgloss.RoundedBorder()) vt.LeftGutterFunc = func(ctx GutterContext) string { - if ctx.Soft { - return " " - } - return fmt.Sprintf("%d ", ctx.Index) + return " " } vt.SetContent(textContentList) - view := vt.View() - if w, h := lipgloss.Size(view); w != width || h != height { + if w, h := lipgloss.Size(vt.View()); w != width || h != height { t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) } golden.RequireEqual(withSuffix(t, "at-top"), vt.View()) vt.ScrollDown(1) + if w, h := lipgloss.Size(vt.View()); w != width || h != height { + t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) + } golden.RequireEqual(withSuffix(t, "scrolled-plus-1"), vt.View()) vt.ScrollDown(1) + if w, h := lipgloss.Size(vt.View()); w != width || h != height { + t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) + } golden.RequireEqual(withSuffix(t, "scrolled-plus-2"), vt.View()) vt.GotoBottom() + if w, h := lipgloss.Size(vt.View()); w != width || h != height { + t.Errorf("view size should be %d x %d, got %d x %d", width, height, w, h) + } golden.RequireEqual(withSuffix(t, "at-bottom"), vt.View()) }) From 7849e057659e1b3c4825871d3f58a393f81b8e87 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Tue, 2 Sep 2025 18:26:55 -0600 Subject: [PATCH 3/4] fix: table tests by using explicit dimensions Signed-off-by: Liam Stanley --- table/table_test.go | 34 +++++++++++---- .../TestModel_View/Bordered_headers.golden | 42 ++++++++++--------- table/testdata/TestModel_View/Empty.golden | 39 ++++++++--------- .../TestModel_View/Extra_padding.golden | 18 ++++---- ...golden => Height_greater_than_rows.golden} | 0 ...ws.golden => Height_less_than_rows.golden} | 0 ...lden => Width_greater_than_columns.golden} | 0 ....golden => Width_less_than_columns.golden} | 0 8 files changed, 78 insertions(+), 55 deletions(-) rename table/testdata/TestModel_View/{Manual_height_greater_than_rows.golden => Height_greater_than_rows.golden} (100%) rename table/testdata/TestModel_View/{Manual_height_less_than_rows.golden => Height_less_than_rows.golden} (100%) rename table/testdata/TestModel_View/{Manual_width_greater_than_columns.golden => Width_greater_than_columns.golden} (100%) rename table/testdata/TestModel_View/{Manual_width_less_than_columns.golden => Width_less_than_columns.golden} (100%) diff --git a/table/table_test.go b/table/table_test.go index 78db2424b..6a4991198 100644 --- a/table/table_test.go +++ b/table/table_test.go @@ -291,6 +291,7 @@ func TestModel_RenderRow(t *testing.T) { func TestTableAlignment(t *testing.T) { t.Run("No border", func(t *testing.T) { biscuits := New( + WithWidth(59), WithHeight(5), WithColumns([]Column{ {Title: "Name", Width: 25}, @@ -319,6 +320,7 @@ func TestTableAlignment(t *testing.T) { Bold(false) biscuits := New( + WithWidth(59), WithHeight(5), WithColumns([]Column{ {Title: "Name", Width: 25}, @@ -515,15 +517,19 @@ func TestModel_View(t *testing.T) { modelFunc func() Model skip bool }{ - // TODO(?): should the view/output of empty tables use the same default height? (this has height 21) "Empty": { modelFunc: func() Model { - return New() + return New( + WithWidth(60), + WithHeight(21), + ) }, }, "Single row and column": { modelFunc: func() Model { return New( + WithWidth(27), + WithHeight(21), WithColumns([]Column{ {Title: "Name", Width: 25}, }), @@ -536,6 +542,8 @@ func TestModel_View(t *testing.T) { "Multiple rows and columns": { modelFunc: func() Model { return New( + WithWidth(59), + WithHeight(21), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -557,6 +565,7 @@ func TestModel_View(t *testing.T) { s.Cell = lipgloss.NewStyle().Padding(2, 2) return New( + WithWidth(60), WithHeight(10), WithColumns([]Column{ {Title: "Name", Width: 25}, @@ -579,6 +588,7 @@ func TestModel_View(t *testing.T) { s.Cell = lipgloss.NewStyle() return New( + WithWidth(53), WithHeight(10), WithColumns([]Column{ {Title: "Name", Width: 25}, @@ -594,10 +604,12 @@ func TestModel_View(t *testing.T) { ) }, }, - // TODO(?): the total height is modified with borderd headers, however not with bordered cells. Is this expected/desired? + // TODO(?): the total height is modified with bordered headers, however not with bordered cells. Is this expected/desired? "Bordered headers": { modelFunc: func() Model { return New( + WithWidth(59), + WithHeight(23), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -618,6 +630,8 @@ func TestModel_View(t *testing.T) { "Bordered cells": { modelFunc: func() Model { return New( + WithWidth(59), + WithHeight(21), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -634,9 +648,10 @@ func TestModel_View(t *testing.T) { ) }, }, - "Manual height greater than rows": { + "Height greater than rows": { modelFunc: func() Model { return New( + WithWidth(59), WithHeight(6), WithColumns([]Column{ {Title: "Name", Width: 25}, @@ -651,9 +666,10 @@ func TestModel_View(t *testing.T) { ) }, }, - "Manual height less than rows": { + "Height less than rows": { modelFunc: func() Model { return New( + WithWidth(59), WithHeight(2), WithColumns([]Column{ {Title: "Name", Width: 25}, @@ -669,10 +685,11 @@ func TestModel_View(t *testing.T) { }, }, // TODO(fix): spaces are added to the right of the viewport to fill the width, but the headers end as though they are not aware of the width. - "Manual width greater than columns": { + "Width greater than columns": { modelFunc: func() Model { return New( WithWidth(80), + WithHeight(21), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -688,10 +705,11 @@ func TestModel_View(t *testing.T) { }, // TODO(fix): Setting the table width does not affect the total headers' width. Cells are wrapped. // Headers are not affected. Truncation/resizing should match lipgloss.table functionality. - "Manual width less than columns": { + "Width less than columns": { modelFunc: func() Model { return New( WithWidth(30), + WithHeight(15), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, @@ -709,6 +727,8 @@ func TestModel_View(t *testing.T) { "Modified viewport height": { modelFunc: func() Model { m := New( + WithWidth(59), + WithHeight(15), WithColumns([]Column{ {Title: "Name", Width: 25}, {Title: "Country of Origin", Width: 16}, diff --git a/table/testdata/TestModel_View/Bordered_headers.golden b/table/testdata/TestModel_View/Bordered_headers.golden index 0e260bae2..c419d8079 100644 --- a/table/testdata/TestModel_View/Bordered_headers.golden +++ b/table/testdata/TestModel_View/Bordered_headers.golden @@ -1,23 +1,25 @@ ┌─────────────────────────┐┌────────────────┐┌────────────┐ │Name ││Country of Orig…││Dunk-able │ └─────────────────────────┘└────────────────┘└────────────┘ -Chocolate Digestives UK Yes -Tim Tams Australia No -Hobnobs UK Yes - - - - - - - - - - - - - - - - - \ No newline at end of file +Chocolate Digestives UK Yes +Tim Tams Australia No +Hobnobs UK Yes + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Empty.golden b/table/testdata/TestModel_View/Empty.golden index 7b050800f..3cd0eb3b2 100644 --- a/table/testdata/TestModel_View/Empty.golden +++ b/table/testdata/TestModel_View/Empty.golden @@ -1,20 +1,21 @@ - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Extra_padding.golden b/table/testdata/TestModel_View/Extra_padding.golden index d6f6b76e8..767dae5c3 100644 --- a/table/testdata/TestModel_View/Extra_padding.golden +++ b/table/testdata/TestModel_View/Extra_padding.golden @@ -3,12 +3,12 @@   Name     Country of Orig…    Dunk-able    - - -  Chocolate Digestives     UK     Yes    - - - - -  Tim Tams     Australia     No    - \ No newline at end of file + + +  Chocolate Digestives     UK     Yes + + + + +  Tim Tams     Australia     No + \ No newline at end of file diff --git a/table/testdata/TestModel_View/Manual_height_greater_than_rows.golden b/table/testdata/TestModel_View/Height_greater_than_rows.golden similarity index 100% rename from table/testdata/TestModel_View/Manual_height_greater_than_rows.golden rename to table/testdata/TestModel_View/Height_greater_than_rows.golden diff --git a/table/testdata/TestModel_View/Manual_height_less_than_rows.golden b/table/testdata/TestModel_View/Height_less_than_rows.golden similarity index 100% rename from table/testdata/TestModel_View/Manual_height_less_than_rows.golden rename to table/testdata/TestModel_View/Height_less_than_rows.golden diff --git a/table/testdata/TestModel_View/Manual_width_greater_than_columns.golden b/table/testdata/TestModel_View/Width_greater_than_columns.golden similarity index 100% rename from table/testdata/TestModel_View/Manual_width_greater_than_columns.golden rename to table/testdata/TestModel_View/Width_greater_than_columns.golden diff --git a/table/testdata/TestModel_View/Manual_width_less_than_columns.golden b/table/testdata/TestModel_View/Width_less_than_columns.golden similarity index 100% rename from table/testdata/TestModel_View/Manual_width_less_than_columns.golden rename to table/testdata/TestModel_View/Width_less_than_columns.golden From fb23df53fdff715eebe2dbeba5f7091a31873322 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Tue, 2 Sep 2025 19:01:39 -0600 Subject: [PATCH 4/4] fix(viewport): fix datarace in gutter/stylelines Signed-off-by: Liam Stanley --- viewport/viewport.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/viewport/viewport.go b/viewport/viewport.go index 9c972e04d..a439aac09 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -337,7 +337,7 @@ func (m Model) visibleLines() (lines []string) { total, ridx, voffset := m.calculateLine(m.YOffset()) if total > 0 { bottom := clamp(ridx+maxHeight, ridx, len(m.lines)) - lines = m.styleLines(m.lines[ridx:bottom], ridx) + lines = m.styleLines(slices.Clone(m.lines[ridx:bottom]), ridx) lines = m.highlightLines(lines, ridx) }