From 2d53a618a93c9cf2d3dd066d1b913fc6f46084cc Mon Sep 17 00:00:00 2001 From: Roman Date: Fri, 10 Jan 2025 12:38:48 +0100 Subject: [PATCH] feat(viewport): horizontal scroll (#240) * horizontal scroll * rebase branch * add tests * add tests with 2 cells symbols * trimLeft, move to charmbracelete/x/ansi lib * up ansi package * Update viewport/viewport.go Co-authored-by: Carlos Alexandro Becker * fix: do not navigate out to the right * fix: cache line width on setcontent * fix tests * fix viewport tests * add test for preventing right overscroll * chore(viewport): increase horizontal step to 6 * chore(viewport): make horizontal scroll API better match vertical scroll API * fix: nolint * fix: use ansi.Cut * perf: do not cut anything if not needed * feat: expose HorizontalScrollPercent * fix: do not scroll if width is 0 Signed-off-by: Carlos Alexandro Becker * fix: visible lines take frame into account --------- Signed-off-by: Carlos Alexandro Becker Co-authored-by: Carlos Alexandro Becker Co-authored-by: Christian Rocha --- go.mod | 2 +- go.sum | 4 +- viewport/keymap.go | 10 + viewport/viewport.go | 101 +++++++++- viewport/viewport_test.go | 383 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 491 insertions(+), 9 deletions(-) create mode 100644 viewport/viewport_test.go diff --git a/go.mod b/go.mod index 575d4f5b3..c35957b35 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/charmbracelet/bubbletea v1.1.2 github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v1.0.0 - github.com/charmbracelet/x/ansi v0.4.2 + github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 github.com/dustin/go-humanize v1.0.1 github.com/lucasb-eyer/go-colorful v1.2.0 diff --git a/go.sum b/go.sum index bd41a6f3e..7542d371a 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,8 @@ github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= -github.com/charmbracelet/x/ansi v0.4.2 h1:0JM6Aj/g/KC154/gOP4vfxun0ff6itogDYk41kof+qk= -github.com/charmbracelet/x/ansi v0.4.2/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5 h1:TSjbA80sXnABV/Vxhnb67Ho7p8bEYqz6NIdhLAx+1yg= +github.com/charmbracelet/x/ansi v0.6.1-0.20250107110353-48b574af22a5/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= 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/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= diff --git a/viewport/keymap.go b/viewport/keymap.go index 9289706a8..060bb8786 100644 --- a/viewport/keymap.go +++ b/viewport/keymap.go @@ -15,6 +15,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. @@ -44,5 +46,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 f220d1eb6..1835b8d0d 100644 --- a/viewport/viewport.go +++ b/viewport/viewport.go @@ -7,6 +7,11 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" +) + +const ( + defaultHorizontalStep = 6 ) // New returns a new model with the given width and height as well as default @@ -34,6 +39,13 @@ 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 @@ -54,8 +66,9 @@ type Model struct { // Deprecated: high performance rendering is now deprecated in Bubble Tea. HighPerformanceRendering bool - initialized bool - lines []string + initialized bool + lines []string + longestLineWidth int } func (m *Model) setInitialValues() { @@ -63,6 +76,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. @@ -99,10 +113,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() @@ -118,12 +146,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 } // scrollArea returns the scrollable boundaries for high performance rendering. @@ -273,7 +313,7 @@ func ViewDown(m Model, lines []string) tea.Cmd { // XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we // won't need to return a command here. - return tea.ScrollDown(lines, top, bottom) + return tea.ScrollDown(lines, top, bottom) //nolint:staticcheck } // ViewUp is a high performance command the moves the viewport down by a given @@ -287,7 +327,40 @@ func ViewUp(m Model, lines []string) tea.Cmd { // XXX: high performance rendering is deprecated in Bubble Tea. In a v2 we // won't need to return a command here. - return tea.ScrollUp(lines, top, bottom) + return tea.ScrollUp(lines, top, bottom) //nolint:staticcheck +} + +// 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. @@ -344,6 +417,12 @@ func (m Model) updateAsModel(msg tea.Msg) (Model, tea.Cmd) { if m.HighPerformanceRendering { cmd = ViewUp(m, lines) } + + case key.Matches(msg, m.KeyMap.Left): + m.MoveLeft(m.horizontalStep) + + case key.Matches(msg, m.KeyMap.Right): + m.MoveRight(m.horizontalStep) } case tea.MouseMsg: @@ -418,3 +497,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 000000000..ef70a54f5 --- /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(10, 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(10, 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(10, 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(10, 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(10, 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(10, 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(10, 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(10, 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(10, 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(10, numberOfLines) + 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(10, numberOfLines) + 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(10, numberOfLines) + 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(20, numberOfLines) + 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(len(content)+1, 5) + 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") + } + }) +}