mirror of
https://github.com/zoriya/bubbles.git
synced 2026-08-05 04:36:07 +00:00
refactor(viewport): softwrap; improve perf; various bug fixes (#823)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+27
-7
@@ -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},
|
||||
|
||||
@@ -21,3 +21,5 @@ Hobnobs UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
@@ -17,4 +17,5 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@
|
||||
|
||||
|
||||
|
||||
Chocolate Digestives UK Yes
|
||||
Chocolate Digestives UK Yes
|
||||
|
||||
|
||||
|
||||
|
||||
Tim Tams Australia No
|
||||
Tim Tams Australia No
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
╭────────────────────────────────────────╮
|
||||
│ll know how many foes you've defeated. │
|
||||
╰────────────────────────────────────────╯
|
||||
@@ -0,0 +1,3 @@
|
||||
╭────────────────────────────────────────╮
|
||||
│cter Zote from an awesome "Hollow knight│
|
||||
╰────────────────────────────────────────╯
|
||||
@@ -0,0 +1,3 @@
|
||||
╭────────────────────────────────────────╮
|
||||
│" game (https://store.steampowered.com/a│
|
||||
╰────────────────────────────────────────╯
|
||||
@@ -0,0 +1,3 @@
|
||||
╭────────────────────────────────────────╮
|
||||
│57 Precepts of narcissistic comedy chara│
|
||||
╰────────────────────────────────────────╯
|
||||
@@ -0,0 +1,3 @@
|
||||
╭────────────────────────────────────────╮
|
||||
│57 Precepts of narcissistic comedy chara│
|
||||
╰────────────────────────────────────────╯
|
||||
@@ -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'. │
|
||||
╰──────────────────────────────────────╯
|
||||
@@ -0,0 +1,15 @@
|
||||
57 Precepts of narcissistic comedy character Zote
|
||||
awesome "Hollow knight" game
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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. │
|
||||
╰────────────────────────────────────────────────╯
|
||||
@@ -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│
|
||||
╰────────────────────────────────────────────────╯
|
||||
@@ -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. │
|
||||
╰────────────────────────────────────────────────╯
|
||||
@@ -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 │
|
||||
╰────────────────────────────────────────────────╯
|
||||
+15
@@ -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 │
|
||||
╰────────────────────────────────────────────────╯
|
||||
+15
@@ -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│
|
||||
╰────────────────────────────────────────────────╯
|
||||
@@ -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 │
|
||||
╰────────────────────────────────────────────────╯
|
||||
@@ -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 │
|
||||
╰────────────────────────────────────────────────╯
|
||||
+128
-120
@@ -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,8 +135,13 @@ var NoGutter = func(GutterContext) string { return "" }
|
||||
|
||||
// GutterContext provides context to a [GutterFunc].
|
||||
type GutterContext struct {
|
||||
// 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 is whether or not the line is soft wrapped.
|
||||
Soft bool
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
total = len(m.lines)
|
||||
ridx = min(yoffset, len(m.lines))
|
||||
return total, ridx, 0
|
||||
}
|
||||
|
||||
maxWidth := float64(m.maxWidth())
|
||||
var lineHeight int
|
||||
|
||||
for i, line := range m.lines {
|
||||
adjust := max(1, lipgloss.Height(line))
|
||||
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)
|
||||
}
|
||||
return total, idx
|
||||
ridx = len(m.lines)
|
||||
voffset = 0
|
||||
}
|
||||
|
||||
maxWidth := m.maxWidth()
|
||||
var gutterSize int
|
||||
if m.LeftGutterFunc != nil {
|
||||
gutterSize = lipgloss.Width(m.LeftGutterFunc(GutterContext{}))
|
||||
}
|
||||
for i, line := range m.lines {
|
||||
adjust := max(1, lipgloss.Width(line)/(maxWidth-gutterSize))
|
||||
if yoffset >= total && yoffset < total+adjust {
|
||||
idx = i
|
||||
}
|
||||
total += adjust
|
||||
}
|
||||
if yoffset >= total {
|
||||
idx = len(m.lines)
|
||||
}
|
||||
return total, idx
|
||||
}
|
||||
|
||||
// lineToIndex taking soft wrappign into account, return the real line index
|
||||
// for the given line.
|
||||
func (m Model) lineToIndex(y int) int {
|
||||
_, idx := m.calculateLine(y)
|
||||
return idx
|
||||
}
|
||||
|
||||
// lineCount taking soft wrapping into account, return the total viewable line
|
||||
// count (real lines + soft wrapped line).
|
||||
func (m Model) lineCount() int {
|
||||
total, _ := m.calculateLine(0)
|
||||
return total
|
||||
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)
|
||||
// Cut the lines to the viewport width.
|
||||
for i := range lines {
|
||||
lines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+maxWidth)
|
||||
}
|
||||
lines[i] = strings.Join(sublines, "\n")
|
||||
}
|
||||
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,
|
||||
lines[i] = m.LeftGutterFunc(GutterContext{
|
||||
Index: i + ridx,
|
||||
TotalLines: total,
|
||||
Soft: j > 0,
|
||||
})+realLine)
|
||||
Soft: false,
|
||||
}) + lines[i]
|
||||
}
|
||||
result[i] = strings.Join(line, "\n")
|
||||
}
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
+220
-40
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user