diff --git a/.gitignore b/.gitignore index e43b0f9..1a07445 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .DS_Store +dist/ diff --git a/.goreleaser.yml b/.goreleaser.yml index c61970e..3353d02 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,5 +1,5 @@ +# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json +version: 2 includes: - from_url: url: charmbracelet/meta/main/goreleaser-lib.yaml -# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json - diff --git a/go.mod b/go.mod index 0a3b0de..4c08477 100644 --- a/go.mod +++ b/go.mod @@ -5,33 +5,30 @@ go 1.18 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/atotto/clipboard v0.1.4 - github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241126192050-a8ed96118b08 + github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0 github.com/charmbracelet/harmonica v0.2.0 - github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20241121164047-8448a9be4804 - github.com/charmbracelet/x/ansi v0.5.1 - github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 + github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607 + github.com/charmbracelet/x/ansi v0.7.0 + github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f 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 - github.com/muesli/termenv v0.15.2 github.com/rivo/uniseg v0.4.7 github.com/sahilm/fuzzy v0.1.1 ) require ( - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-udiff v0.2.0 // indirect - github.com/charmbracelet/colorprofile v0.1.8 // indirect - github.com/charmbracelet/x/cellbuf v0.0.6 // indirect + github.com/charmbracelet/colorprofile v0.1.9 // indirect + github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 // indirect + github.com/charmbracelet/x/input v0.3.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4 // indirect github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 // indirect github.com/charmbracelet/x/windows v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.9.0 // indirect - golang.org/x/sys v0.27.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 277a374..2346187 100644 --- a/go.sum +++ b/go.sum @@ -2,28 +2,26 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 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/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241126192050-a8ed96118b08 h1:8hwULvCHjF6JjaeosebMGbB06oCv46d4s+Lbs5ytAT4= -github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20241126192050-a8ed96118b08/go.mod h1:BbC4R+6e9TLjbskxrjISt/DDCn4OiB6v+ArqfYiPyyg= -github.com/charmbracelet/colorprofile v0.1.8 h1:PywDeXsiAzlPtkiiKgMEVLvb6nlEuKrMj9+FJBtj4jU= -github.com/charmbracelet/colorprofile v0.1.8/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0 h1:BWjXQRSwBjoCpLeNu8zT93n+NHhZZhkQQLveXMmnkYc= +github.com/charmbracelet/bubbletea/v2 v2.0.0-alpha.2.0.20250114201644-43a5b4dd0af0/go.mod h1:hT2875Ank3ylgW13kqu6cjDc9XIk9sE5JsOFYdl09b8= +github.com/charmbracelet/colorprofile v0.1.9 h1:5JnfvX+I9D6rRNu8xK3pgIqknaBVTXHU9pGu1jkZxLw= +github.com/charmbracelet/colorprofile v0.1.9/go.mod h1:+jpmObxZl1Dab3H3IMVIPSZTsKcFpjJUv97G0dLqM60= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20241121164047-8448a9be4804 h1:7CYjb9YMZA4kMhLgGdtlXvq+nu1oyENpMyMQlTvqSFw= -github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20241121164047-8448a9be4804/go.mod h1:F/6E/LGdH3eHCJf2rG8/O3CjlW8cZFL5YJCknJs1GkI= -github.com/charmbracelet/x/ansi v0.5.1 h1:+mg6abP9skvsu/JQZrIJ9Z/4O1YDnLVkpfutar3dUnc= -github.com/charmbracelet/x/ansi v0.5.1/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= -github.com/charmbracelet/x/cellbuf v0.0.6 h1:pJUWN/G1jbt1Nj/+ILfC2/ABQoZzWu1vG73yHQEYELI= -github.com/charmbracelet/x/cellbuf v0.0.6/go.mod h1:d72o71glp8flkCz54PHLe3+nuw5u2v3UxmKqruUERWQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607 h1:lERE4ow371r5WMqQAt7Eqlg1A4tBNA8T4RLwdXnKyBo= +github.com/charmbracelet/lipgloss/v2 v2.0.0-alpha.2.0.20250114171829-b67eb015d607/go.mod h1:MD7Vb+O1zFRgBo+F94JHHuME7df8XBByNKuX5k/L/qs= +github.com/charmbracelet/x/ansi v0.7.0 h1:/QfFmiXOGGwN6fRbzvQaYp7fu1pkxpZ3qFBZWBsP404= +github.com/charmbracelet/x/ansi v0.7.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= +github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72 h1:P90NI2rZuBISjB1HIHdkBDE+riKtVzIOi6Xun3qjUn8= +github.com/charmbracelet/x/cellbuf v0.0.7-0.20250113065325-800d48271e72/go.mod h1:VXZSjC/QYH0t+9CG1qtcEx3XZubTDJb5ilWS6qJg4/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/input v0.3.0 h1:lVzEz92E2u9jCU0mUwcyKeSOxkoeat+1eUkjzL0WCYI= +github.com/charmbracelet/x/input v0.3.0/go.mod h1:M8CHPIYnmmiNHA17hqXmvSfeZLO2lj9pzJFX3aWvzgw= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4 h1:EacjHxcQEEgOZ7TbkAU3b84hd1Bn5NwA8YV5uyJ9EI4= -github.com/charmbracelet/x/vt v0.0.0-20241121165045-a3720547cbb4/go.mod h1:1/jFoHl7/I4br0StC9OXXEondkK9qi3nUtKoqI35HcI= github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32 h1:14czE6R5CgOlvONsJYa2B1uTyLvXzGXpBqw2AyZeTh4= github.com/charmbracelet/x/wcwidth v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:hyua5CY63kyl7IfyIxv1SjVEqoKze/XmDkEglItuVjA= github.com/charmbracelet/x/windows v0.2.0 h1:ilXA1GJjTNkgOm94CLPeSz7rar54jtFatdmoiONPuEw= @@ -34,14 +32,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -50,10 +44,9 @@ github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/progress/progress.go b/progress/progress.go index b4c9129..b596c30 100644 --- a/progress/progress.go +++ b/progress/progress.go @@ -2,6 +2,7 @@ package progress import ( "fmt" + "image/color" "math" "strings" "sync/atomic" @@ -12,7 +13,6 @@ import ( "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/lucasb-eyer/go-colorful" - "github.com/muesli/termenv" ) // Internal ID management. Used during animating to assure that frame messages @@ -65,7 +65,7 @@ func WithScaledGradient(colorA, colorB string) Option { } // WithSolidFill sets the progress to use a solid fill with the given color. -func WithSolidFill(color string) Option { +func WithSolidFill(color color.Color) Option { return func(m *Model) { m.FullColor = color m.useRamp = false @@ -108,13 +108,6 @@ func WithSpringOptions(frequency, damping float64) Option { } } -// WithColorProfile sets the color profile to use for the progress bar. -func WithColorProfile(p termenv.Profile) Option { - return func(m *Model) { - m.colorProfile = p - } -} - // FrameMsg indicates that an animation step should occur. type FrameMsg struct { id int @@ -135,11 +128,11 @@ type Model struct { // "Filled" sections of the progress bar. Full rune - FullColor string + FullColor color.Color // "Empty" sections of the progress bar. Empty rune - EmptyColor string + EmptyColor color.Color // Settings for rendering the numeric percentage. ShowPercentage bool @@ -162,9 +155,6 @@ type Model struct { // of the progress bar. When false, the width of the gradient will be set // to the full width of the progress bar. scaleRamp bool - - // Color profile for the progress bar. - colorProfile termenv.Profile } // New returns a model with default values. @@ -173,12 +163,11 @@ func New(opts ...Option) Model { id: nextID(), width: defaultWidth, Full: '█', - FullColor: "#7571F9", + FullColor: lipgloss.Color("#7571F9"), Empty: '░', - EmptyColor: "#606060", + EmptyColor: lipgloss.Color("#606060"), ShowPercentage: true, PercentFormat: " %3.0f%%", - colorProfile: termenv.ColorProfile(), } for _, opt := range opts { @@ -316,23 +305,21 @@ func (m Model) barView(b *strings.Builder, percent float64, textWidth int) { } else { p = float64(i) / float64(tw-1) } - c := m.rampColorA.BlendLuv(m.rampColorB, p).Hex() - b.WriteString(termenv. - String(string(m.Full)). - Foreground(m.color(c)). - String(), - ) + c := m.rampColorA.BlendLuv(m.rampColorB, p) + b.WriteString(lipgloss.NewStyle().Foreground(c).Render(string(m.Full))) } } else { // Solid fill - s := termenv.String(string(m.Full)).Foreground(m.color(m.FullColor)).String() - b.WriteString(strings.Repeat(s, fw)) + b.WriteString(lipgloss.NewStyle(). + Foreground(m.FullColor). + Render(strings.Repeat(string(m.Full), fw))) } // Empty fill - e := termenv.String(string(m.Empty)).Foreground(m.color(m.EmptyColor)).String() n := max(0, tw-fw) - b.WriteString(strings.Repeat(e, n)) + b.WriteString(lipgloss.NewStyle(). + Foreground(m.EmptyColor). + Render(strings.Repeat(string(m.Empty), n))) } func (m Model) percentageView(percent float64) string { @@ -358,10 +345,6 @@ func (m *Model) setRamp(colorA, colorB string, scaled bool) { m.rampColorB = b } -func (m Model) color(c string) termenv.Color { - return m.colorProfile.Color(c) -} - func max(a, b int) int { if a > b { return a diff --git a/progress/progress_test.go b/progress/progress_test.go index a4ba720..08bd52b 100644 --- a/progress/progress_test.go +++ b/progress/progress_test.go @@ -4,15 +4,14 @@ import ( "strings" "testing" - "github.com/muesli/termenv" + "github.com/charmbracelet/lipgloss/v2" ) const ( - AnsiReset = "\x1b[0m" + AnsiReset = "\x1b[m" ) func TestGradient(t *testing.T) { - colA := "#FF0000" colB := "#00FF00" @@ -21,7 +20,7 @@ func TestGradient(t *testing.T) { for _, scale := range []bool{false, true} { opts := []Option{ - WithColorProfile(termenv.TrueColor), WithoutPercentage(), + WithoutPercentage(), } if scale { descr = "progress bar with scaled gradient" @@ -36,10 +35,10 @@ func TestGradient(t *testing.T) { // build the expected colors by colorizing an empty string and then cutting off the following reset sequence sb := strings.Builder{} - sb.WriteString(termenv.String("").Foreground(p.color(colA)).String()) + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colA)).String()) expFirst := strings.Split(sb.String(), AnsiReset)[0] sb.Reset() - sb.WriteString(termenv.String("").Foreground(p.color(colB)).String()) + sb.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colB)).String()) expLast := strings.Split(sb.String(), AnsiReset)[0] for _, width := range []int{3, 5, 50} { @@ -62,5 +61,4 @@ func TestGradient(t *testing.T) { } }) } - } diff --git a/textinput/textinput.go b/textinput/textinput.go index 63b0cd9..92c468a 100644 --- a/textinput/textinput.go +++ b/textinput/textinput.go @@ -703,10 +703,12 @@ func (m Model) View() string { func (m Model) placeholderView() string { var ( v string - p = []rune(m.Placeholder) style = m.PlaceholderStyle.Inline(true).Render ) + p := make([]rune, m.Width()+1) + copy(p, []rune(m.Placeholder)) + m.Cursor.TextStyle = m.PlaceholderStyle m.Cursor.SetChar(string(p[:1])) v += m.Cursor.View() diff --git a/textinput/textinput_test.go b/textinput/textinput_test.go index 27a7640..b13f60d 100644 --- a/textinput/textinput_test.go +++ b/textinput/textinput_test.go @@ -30,3 +30,10 @@ func Test_CurrentSuggestion(t *testing.T) { t.Fatalf("Error: expected first suggestion but was %s", suggestion) } } + +func Test_SlicingOutsideCap(t *testing.T) { + textinput := New() + textinput.Placeholder = "作業ディレクトリを指定してください" + textinput.SetWidth(32) + textinput.View() +} diff --git a/viewport/keymap.go b/viewport/keymap.go index 6f66f58..d9c503a 100644 --- a/viewport/keymap.go +++ b/viewport/keymap.go @@ -13,6 +13,8 @@ type KeyMap struct { HalfPageDown key.Binding Down key.Binding Up key.Binding + Left key.Binding + Right key.Binding } // DefaultKeyMap returns a set of pager-like default keybindings. @@ -42,5 +44,13 @@ func DefaultKeyMap() KeyMap { key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"), ), + Left: key.NewBinding( + key.WithKeys("left", "h"), + key.WithHelp("←/h", "move left"), + ), + Right: key.NewBinding( + key.WithKeys("right", "l"), + key.WithHelp("→/l", "move right"), + ), } } diff --git a/viewport/viewport.go b/viewport/viewport.go index 3246478..d4b28e0 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -7,6 +7,11 @@ import ( "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" +) + +const ( + defaultHorizontalStep = 6 ) // Option is a configuration option that works in conjunction with [New]. For @@ -57,12 +62,24 @@ type Model struct { // YOffset is the vertical scroll position. YOffset int + // xOffset is the horizontal scroll position. + xOffset int + + // horizontalStep is the number of columns we move left or right during a + // default horizontal scroll. + horizontalStep int + + // YPosition is the position of the viewport in relation to the terminal + // window. It's used in high performance rendering only. + YPosition int + // Style applies a lipgloss style to the viewport. Realistically, it's most // useful for setting borders, margins and padding. Style lipgloss.Style - initialized bool - lines []string + initialized bool + lines []string + longestLineWidth int } func (m *Model) setInitialValues() { @@ -70,6 +87,7 @@ func (m *Model) setInitialValues() { m.MouseWheelEnabled = true m.MouseWheelDelta = 3 m.initialized = true + m.horizontalStep = defaultHorizontalStep } // Init exists to satisfy the tea.Model interface for composability purposes. @@ -126,10 +144,24 @@ func (m Model) ScrollPercent() float64 { return math.Max(0.0, math.Min(1.0, v)) } +// HorizontalScrollPercent returns the amount horizontally scrolled as a float +// between 0 and 1. +func (m Model) HorizontalScrollPercent() float64 { + if m.xOffset >= m.longestLineWidth-m.Width() { + return 1.0 + } + y := float64(m.xOffset) + h := float64(m.Width()) + t := float64(m.longestLineWidth) + v := y / (t - h) + return math.Max(0.0, math.Min(1.0, v)) +} + // SetContent set the pager's text content. func (m *Model) SetContent(s string) { s = strings.ReplaceAll(s, "\r\n", "\n") // normalize line endings m.lines = strings.Split(s, "\n") + m.longestLineWidth = findLongestLineWidth(m.lines) if m.YOffset > len(m.lines)-1 { m.GotoBottom() @@ -145,12 +177,24 @@ func (m Model) maxYOffset() int { // visibleLines returns the lines that should currently be visible in the // viewport. func (m Model) visibleLines() (lines []string) { + h := m.Height() - m.Style.GetVerticalFrameSize() + w := m.Width() - m.Style.GetHorizontalFrameSize() + if len(m.lines) > 0 { top := max(0, m.YOffset) - bottom := clamp(m.YOffset+m.Height(), top, len(m.lines)) + bottom := clamp(m.YOffset+h, top, len(m.lines)) lines = m.lines[top:bottom] } - return lines + + if (m.xOffset == 0 && m.longestLineWidth <= w) || w == 0 { + return lines + } + + cutLines := make([]string, len(lines)) + for i := range lines { + cutLines[i] = ansi.Cut(lines[i], m.xOffset, m.xOffset+w) + } + return cutLines } // SetYOffset sets the Y offset. @@ -245,6 +289,39 @@ func (m *Model) GotoBottom() (lines []string) { return m.visibleLines() } +// SetHorizontalStep sets the amount of cells that the viewport moves in the +// default viewport keymapping. If set to 0 or less, horizontal scrolling is +// disabled. +func (m *Model) SetHorizontalStep(n int) { + if n < 0 { + n = 0 + } + + m.horizontalStep = n +} + +// MoveLeft moves the viewport to the left by the given number of columns. +func (m *Model) MoveLeft(cols int) { + m.xOffset -= cols + if m.xOffset < 0 { + m.xOffset = 0 + } +} + +// MoveRight moves viewport to the right by the given number of columns. +func (m *Model) MoveRight(cols int) { + // prevents over scrolling to the right + if m.xOffset >= m.longestLineWidth-m.Width() { + return + } + m.xOffset += cols +} + +// Resets lines indent to zero. +func (m *Model) ResetIndent() { + m.xOffset = 0 +} + // Update handles standard message-based viewport updates. func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { m = m.updateAsModel(msg) @@ -278,6 +355,12 @@ func (m Model) updateAsModel(msg tea.Msg) Model { case key.Matches(msg, m.KeyMap.Up): m.LineUp(1) + + case key.Matches(msg, m.KeyMap.Left): + m.MoveLeft(m.horizontalStep) + + case key.Matches(msg, m.KeyMap.Right): + m.MoveRight(m.horizontalStep) } case tea.MouseWheelMsg: @@ -339,3 +422,13 @@ func max(a, b int) int { } return b } + +func findLongestLineWidth(lines []string) int { + w := 0 + for _, l := range lines { + if ww := ansi.StringWidth(l); ww > w { + w = ww + } + } + return w +} diff --git a/viewport/viewport_test.go b/viewport/viewport_test.go new file mode 100644 index 0000000..180cddb --- /dev/null +++ b/viewport/viewport_test.go @@ -0,0 +1,383 @@ +package viewport + +import ( + "strings" + "testing" +) + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("default values on create by New", func(t *testing.T) { + t.Parallel() + + m := New(WithHeight(10), WithWidth(10)) + + if !m.initialized { + t.Errorf("on create by New, Model should be initialized") + } + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + + if m.MouseWheelDelta != 3 { + t.Errorf("default MouseWheelDelta should be 3, got %d", m.MouseWheelDelta) + } + + if !m.MouseWheelEnabled { + t.Error("mouse wheel should be enabled by default") + } + }) +} + +func TestSetInitialValues(t *testing.T) { + t.Parallel() + + t.Run("default horizontalStep", func(t *testing.T) { + t.Parallel() + + m := Model{} + m.setInitialValues() + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + }) +} + +func TestSetHorizontalStep(t *testing.T) { + t.Parallel() + + t.Run("change default", func(t *testing.T) { + t.Parallel() + + m := New(WithHeight(10), WithWidth(10)) + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + + newStep := 8 + m.SetHorizontalStep(newStep) + if m.horizontalStep != newStep { + t.Errorf("horizontalStep should be %d, got %d", newStep, m.horizontalStep) + } + }) + + t.Run("no negative", func(t *testing.T) { + t.Parallel() + + m := New(WithHeight(10), WithWidth(10)) + + if m.horizontalStep != defaultHorizontalStep { + t.Errorf("default horizontalStep should be %d, got %d", defaultHorizontalStep, m.horizontalStep) + } + + zero := 0 + m.SetHorizontalStep(-1) + if m.horizontalStep != zero { + t.Errorf("horizontalStep should be %d, got %d", zero, m.horizontalStep) + } + }) +} + +func TestMoveLeft(t *testing.T) { + t.Parallel() + + zeroPosition := 0 + + t.Run("zero position", func(t *testing.T) { + t.Parallel() + + m := New(WithHeight(10), WithWidth(10)) + if m.xOffset != zeroPosition { + t.Errorf("default indent should be %d, got %d", zeroPosition, m.xOffset) + } + + m.MoveLeft(m.horizontalStep) + if m.xOffset != zeroPosition { + t.Errorf("indent should be %d, got %d", zeroPosition, m.xOffset) + } + }) + + t.Run("move", func(t *testing.T) { + t.Parallel() + m := New(WithHeight(10), WithWidth(10)) + if m.xOffset != zeroPosition { + t.Errorf("default indent should be %d, got %d", zeroPosition, m.xOffset) + } + + m.xOffset = defaultHorizontalStep * 2 + m.MoveLeft(m.horizontalStep) + newIndent := defaultHorizontalStep + if m.xOffset != newIndent { + t.Errorf("indent should be %d, got %d", newIndent, m.xOffset) + } + }) +} + +func TestMoveRight(t *testing.T) { + t.Parallel() + + t.Run("move", func(t *testing.T) { + t.Parallel() + + zeroPosition := 0 + + m := New(WithHeight(10), WithWidth(10)) + m.SetContent("Some line that is longer than width") + if m.xOffset != zeroPosition { + t.Errorf("default indent should be %d, got %d", zeroPosition, m.xOffset) + } + + m.MoveRight(m.horizontalStep) + newIndent := defaultHorizontalStep + if m.xOffset != newIndent { + t.Errorf("indent should be %d, got %d", newIndent, m.xOffset) + } + }) +} + +func TestResetIndent(t *testing.T) { + t.Parallel() + + t.Run("reset", func(t *testing.T) { + t.Parallel() + + zeroPosition := 0 + + m := New(WithHeight(10), WithWidth(10)) + m.xOffset = 500 + + m.ResetIndent() + if m.xOffset != zeroPosition { + t.Errorf("indent should be %d, got %d", zeroPosition, m.xOffset) + } + }) +} + +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.`, + `...`, + } + + t.Run("empty list", func(t *testing.T) { + t.Parallel() + + m := New(WithHeight(10), WithWidth(10)) + list := m.visibleLines() + + if len(list) != 0 { + t.Errorf("list should be empty, got %d", len(list)) + } + }) + + t.Run("empty list: with indent", func(t *testing.T) { + t.Parallel() + + m := New(WithHeight(10), WithWidth(10)) + list := m.visibleLines() + m.xOffset = 5 + + if len(list) != 0 { + t.Errorf("list should be empty, got %d", len(list)) + } + }) + + t.Run("list", func(t *testing.T) { + t.Parallel() + numberOfLines := 10 + + m := New(WithHeight(numberOfLines), WithWidth(10)) + m.SetContent(strings.Join(defaultList, "\n")) + + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + lastItemIdx := numberOfLines - 1 + // we trim line if it doesn't fit to width of the viewport + shouldGet := defaultList[lastItemIdx][:m.Width()] + if list[lastItemIdx] != shouldGet { + t.Errorf(`%dth list item should be '%s', got '%s'`, lastItemIdx, shouldGet, list[lastItemIdx]) + } + }) + + t.Run("list: with y offset", func(t *testing.T) { + t.Parallel() + numberOfLines := 10 + + m := New(WithHeight(numberOfLines), WithWidth(10)) + m.SetContent(strings.Join(defaultList, "\n")) + m.YOffset = 5 + + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + if list[0] == defaultList[0] { + t.Error("first item of list should not be the first item of initial list because of Y offset") + } + + lastItemIdx := numberOfLines - 1 + // we trim line if it doesn't fit to width of the viewport + shouldGet := defaultList[m.YOffset+lastItemIdx][:m.Width()] + if list[lastItemIdx] != shouldGet { + t.Errorf(`%dth list item should be '%s', got '%s'`, lastItemIdx, shouldGet, list[lastItemIdx]) + } + }) + + t.Run("list: with y offset: horizontal scroll", func(t *testing.T) { + t.Parallel() + numberOfLines := 10 + + m := New(WithHeight(numberOfLines), WithWidth(10)) + m.lines = defaultList + m.YOffset = 7 + + // default list + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + lastItem := numberOfLines - 1 + defaultLastItem := len(defaultList) - 1 + if list[lastItem] != defaultList[defaultLastItem] { + t.Errorf("%dth list item should the the same as %dth default list item", lastItem, defaultLastItem) + } + + perceptPrefix := "Precept" + if !strings.HasPrefix(list[0], perceptPrefix) { + t.Errorf("first list item has to have prefix %s", perceptPrefix) + } + + // move right + m.MoveRight(m.horizontalStep) + list = m.visibleLines() + + newPrefix := perceptPrefix[m.xOffset:] + if !strings.HasPrefix(list[0], newPrefix) { + t.Errorf("first list item has to have prefix %s, get %s", newPrefix, list[0]) + } + + if list[lastItem] != "..." { + t.Errorf("last item should be empty, got %s", list[lastItem]) + } + + // move left + m.MoveLeft(m.horizontalStep) + list = m.visibleLines() + if !strings.HasPrefix(list[0], perceptPrefix) { + t.Errorf("first list item has to have prefix %s", perceptPrefix) + } + + if list[lastItem] != defaultList[defaultLastItem] { + t.Errorf("%dth list item should the the same as %dth default list item", lastItem, defaultLastItem) + } + }) + + t.Run("list: with 2 cells symbols: horizontal scroll", func(t *testing.T) { + t.Parallel() + + const horizontalStep = 5 + + initList := []string{ + "あいうえお", + "Aあいうえお", + "あいうえお", + "Aあいうえお", + } + numberOfLines := len(initList) + + m := New(WithHeight(numberOfLines), WithWidth(20)) + m.lines = initList + m.longestLineWidth = 30 // dirty hack: not checking right overscroll for this test case + + // default list + list := m.visibleLines() + if len(list) != numberOfLines { + t.Errorf("list should have %d lines, got %d", numberOfLines, len(list)) + } + + lastItemIdx := numberOfLines - 1 + initLastItem := len(initList) - 1 + shouldGet := initList[initLastItem] + if list[lastItemIdx] != shouldGet { + t.Errorf("%dth list item should the the same as %dth default list item", lastItemIdx, initLastItem) + } + + // move right + m.MoveRight(horizontalStep) + list = m.visibleLines() + + for i := range list { + cutLine := "うえお" + if list[i] != cutLine { + t.Errorf("line must be `%s`, get `%s`", cutLine, list[i]) + } + } + + // move left + m.MoveLeft(horizontalStep) + list = m.visibleLines() + for i := range list { + if list[i] != initList[i] { + t.Errorf("line must be `%s`, get `%s`", list[i], initList[i]) + } + } + + // move left second times do not change lites if indent == 0 + m.xOffset = 0 + m.MoveLeft(horizontalStep) + list = m.visibleLines() + for i := range list { + if list[i] != initList[i] { + t.Errorf("line must be `%s`, get `%s`", list[i], initList[i]) + } + } + }) +} + +func TestRightOverscroll(t *testing.T) { + t.Parallel() + + t.Run("prevent right overscroll", func(t *testing.T) { + t.Parallel() + content := "Content is short" + m := New(WithHeight(5), WithWidth(len(content)+1)) + m.SetContent(content) + + for i := 0; i < 10; i++ { + m.MoveRight(m.horizontalStep) + } + + visibleLines := m.visibleLines() + visibleLine := visibleLines[0] + + if visibleLine != content { + t.Error("visible line should stay the same as content") + } + }) +}