diff --git a/go.mod b/go.mod index 2f022a5f..a944e6b9 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 5d01a667..d052c8f0 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/table/table_test.go b/table/table_test.go index 78db2424..6a499119 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 0e260bae..c419d807 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 7b050800..3cd0eb3b 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 d6f6b76e..767dae5c 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 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 00000000..d76bc057 --- /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 00000000..07c2c3b5 --- /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 00000000..a138c512 --- /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 00000000..327de0bd --- /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 00000000..327de0bd --- /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 00000000..72b5294d --- /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 00000000..236c8c1c --- /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 00000000..6dd3f44b --- /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 00000000..7cc481a6 --- /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 00000000..af97b534 --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-bottom.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│ 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 intel│ +│ lect or both, you need to show them your respe│ +│ ct. Don't ignore them or laugh at them. │ +│ 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 00000000..28b808d1 --- /dev/null +++ b/viewport/testdata/TestSizing/view-50x15-softwrap-gutter-at-top.golden @@ -0,0 +1,15 @@ +╭────────────────────────────────────────────────╮ +│ 57 Precepts of narcissistic comedy character Z│ +│ ote from an awesome "Hollow knight" game (http│ +│ s://store.steampowered.com/app/367520/Hollow_K│ +│ night/). │ +│ 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! │ +│ │ +│ 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 00000000..cf1f12fd --- /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/). │ +│ 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! │ +│ │ +│ 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 00000000..131dca04 --- /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/). │ +│ 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! │ +│ │ +│ 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 00000000..5ce9bf6d --- /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 00000000..7d5f750b --- /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 624e2912..a439aac0 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,26 +219,37 @@ 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. -// 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")) } // 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. + var subLines []string + for i := len(m.lines) - 1; i >= 0; i-- { + m.lines[i] = strings.ReplaceAll(m.lines[i], "\r\n", "\n") // normalize line endings + + 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] + } + } } + m.longestLineWidth = maxLineWidth(m.lines) m.ClearHighlights() @@ -244,59 +264,42 @@ 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) { if !m.SoftWrap { - for i, line := range m.lines { - adjust := max(1, lipgloss.Height(line)) - if yoffset >= total && yoffset < total+adjust { - idx = i - } - total += adjust - } - if yoffset >= total { - idx = len(m.lines) - } - return total, idx + total = len(m.lines) + ridx = min(yoffset, len(m.lines)) + return total, ridx, 0 } - maxWidth := m.maxWidth() - var gutterSize int - if m.LeftGutterFunc != nil { - gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{})) - } + maxWidth := float64(m.maxWidth()) + var lineHeight int + 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 + voffset = yoffset - total } - total += adjust + total += lineHeight } + if yoffset >= total { - idx = len(m.lines) + ridx = len(m.lines) + voffset = 0 } - 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 @@ -305,18 +308,20 @@ 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 { - 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) } +// maxHeight returns the maximum height of the viewport. It accounts for the frame +// size. 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 +330,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(slices.Clone(m.lines[ridx:bottom]), ridx) + lines = m.highlightLines(lines, ridx) } for m.FillHeight && len(lines) < maxHeight { @@ -341,21 +347,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, ridx, 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,16 +400,35 @@ 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, ridx, 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 { + if m.LeftGutterFunc != nil { + line = m.LeftGutterFunc(GutterContext{ + Index: i + ridx, + TotalLines: total, + Soft: false, + }) + line + } + 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(), + Index: i + ridx, TotalLines: total, Soft: idx > 0, }) + truncatedLine @@ -415,30 +437,24 @@ 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() - result := make([]string, len(lines)) for i := range lines { - var line []string - for j, realLine := range strings.Split(lines[i], "\n") { - line = append(line, m.LeftGutterFunc(GutterContext{ - Index: i + offset, - TotalLines: total, - Soft: j > 0, - })+realLine) - } - result[i] = strings.Join(line, "\n") + lines[i] = m.LeftGutterFunc(GutterContext{ + Index: i + ridx, + TotalLines: total, + Soft: false, + }) + lines[i] } - return result + return lines } // SetYOffset sets the Y offset. @@ -461,8 +477,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 +484,6 @@ func (m *Model) PageDown() { if m.AtBottom() { return } - m.ScrollDown(m.Height()) } @@ -479,7 +492,6 @@ func (m *Model) PageUp() { if m.AtTop() { return } - m.ScrollUp(m.Height()) } @@ -488,7 +500,6 @@ func (m *Model) HalfPageDown() { if m.AtBottom() { return } - m.ScrollDown(m.Height() / 2) //nolint:mnd } @@ -497,7 +508,6 @@ func (m *Model) HalfPageUp() { if m.AtTop() { return } - m.ScrollUp(m.Height() / 2) //nolint:mnd } @@ -506,25 +516,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 +565,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 +579,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 +604,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 +627,6 @@ func (m *Model) HighlightNext() { if m.highlights == nil { return } - m.hiIdx = (m.hiIdx + 1) % len(m.highlights) m.showHighlight() } @@ -630,12 +636,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 +730,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 +756,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 bf504ddd..7af40a4c 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,191 @@ 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 { + return " " + } + vt.SetContent(textContentList) + + 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()) + }) + + 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() } }) }