feat: implementing scrolling cmd output window: tested
All checks were successful
Run Test Suite / test (push) Successful in 42s

This commit is contained in:
Hayden Hargreaves 2026-03-19 14:32:21 -07:00
parent b618e3a382
commit 5ff473d0d9
4 changed files with 637 additions and 5 deletions

View File

@ -1,6 +1,8 @@
package core package core
import "strings" import (
"strings"
)
const CommandOutputExitMessage = "Press ENTER to continue" const CommandOutputExitMessage = "Press ENTER to continue"
@ -37,3 +39,39 @@ func (c *CommandOutput) Height() int {
func (c *CommandOutput) IsActive() bool { func (c *CommandOutput) IsActive() bool {
return len(c.Lines) > 0 return len(c.Lines) > 0
} }
// maxOutputWindowHeight: Calculates the max height of the output window. This is simply
// just 3/4 of the terminal height. This allows the title to always be shown, but also
// allows the user to not totally lose mental context when viewing a large output.
func maxOutputWindowHeight(termHeight int) int {
return int(float64(termHeight) * 0.75)
}
// CommandOutput.Viewport: Returns a list of the lines in the current viewport, depends on
// the height of the terminal. This function should be in place of the Lines property.
func (c *CommandOutput) Viewport(height int) []string {
start := c.ScrollOffset
end := maxOutputWindowHeight(height) + start
// Clamp end to available lines
if end > len(c.Lines) {
end = len(c.Lines)
}
return c.Lines[start:end]
}
// CommandOutput.ScrollDown: Manages the scrolling down logic and handles bounds checks.
// This function depends on the height on the terminal.
func (c *CommandOutput) ScrollDown(height int) {
if (c.ScrollOffset + maxOutputWindowHeight(height)) < len(c.Lines) {
c.ScrollOffset++
}
}
// CommandOutput.ScrollUp: Manages the scrolling up logic and handles bounds checks.
func (c *CommandOutput) ScrollUp() {
if c.ScrollOffset > 0 {
c.ScrollOffset--
}
}

View File

@ -0,0 +1,588 @@
package core
import "testing"
// TestCommandOutputHeight tests the Height() method for various configurations
func TestCommandOutputHeight(t *testing.T) {
t.Run("inline mode returns 1", func(t *testing.T) {
co := &CommandOutput{
Title: "Test Title",
Lines: []string{"line1", "line2", "line3"},
Inline: true,
}
if got := co.Height(); got != 1 {
t.Errorf("Height() = %d, want 1 for inline mode", got)
}
})
t.Run("empty output with no title", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{},
Inline: false,
}
// 0 lines + 0 title + 2 padding = 2
want := 2
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("one line with title", func(t *testing.T) {
co := &CommandOutput{
Title: "Test",
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 1 title + 2 padding = 4
want := 4
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("one line without title", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 0 title + 2 padding = 3
want := 3
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("multiple lines with title", func(t *testing.T) {
co := &CommandOutput{
Title: "Output",
Lines: []string{"line1", "line2", "line3", "line4", "line5"},
Inline: false,
}
// 5 lines + 1 title + 2 padding = 8
want := 8
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("whitespace-only title counts as no title", func(t *testing.T) {
co := &CommandOutput{
Title: " ",
Lines: []string{"line1"},
Inline: false,
}
// 1 line + 0 title (whitespace) + 2 padding = 3
want := 3
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
t.Run("empty string title counts as no title", func(t *testing.T) {
co := &CommandOutput{
Title: "",
Lines: []string{"line1", "line2"},
Inline: false,
}
// 2 lines + 0 title + 2 padding = 4
want := 4
if got := co.Height(); got != want {
t.Errorf("Height() = %d, want %d", got, want)
}
})
}
// TestCommandOutputIsActive tests the IsActive() method
func TestCommandOutputIsActive(t *testing.T) {
t.Run("active with lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1"},
}
if !co.IsActive() {
t.Error("IsActive() = false, want true when lines present")
}
})
t.Run("inactive with no lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{},
}
if co.IsActive() {
t.Error("IsActive() = true, want false when no lines")
}
})
t.Run("inactive with nil lines", func(t *testing.T) {
co := &CommandOutput{
Lines: nil,
}
if co.IsActive() {
t.Error("IsActive() = true, want false when lines is nil")
}
})
t.Run("active with multiple lines", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1", "line2", "line3"},
}
if !co.IsActive() {
t.Error("IsActive() = false, want true when multiple lines present")
}
})
}
// TestMaxOutputWindowHeight tests the maxOutputWindowHeight helper function
func TestMaxOutputWindowHeight(t *testing.T) {
tests := []struct {
name string
termHeight int
want int
}{
{"100 height terminal", 100, 75},
{"80 height terminal", 80, 60},
{"40 height terminal", 40, 30},
{"24 height terminal", 24, 18},
{"20 height terminal", 20, 15},
{"10 height terminal", 10, 7},
{"1 height terminal", 1, 0},
{"0 height terminal", 0, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := maxOutputWindowHeight(tt.termHeight)
if got != tt.want {
t.Errorf("maxOutputWindowHeight(%d) = %d, want %d", tt.termHeight, got, tt.want)
}
})
}
}
// TestCommandOutputViewport tests the Viewport() method
func TestCommandOutputViewport(t *testing.T) {
t.Run("viewport at start with small content", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"line1", "line2", "line3"},
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75, viewport shows all 3 lines
viewport := co.Viewport(100)
if len(viewport) != 3 {
t.Errorf("Viewport() returned %d lines, want 3", len(viewport))
}
want := []string{"line1", "line2", "line3"}
for i, line := range viewport {
if line != want[i] {
t.Errorf("Viewport()[%d] = %q, want %q", i, line, want[i])
}
}
})
t.Run("viewport at start with large content", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75
viewport := co.Viewport(100)
if len(viewport) != 75 {
t.Errorf("Viewport() returned %d lines, want 75", len(viewport))
}
// Should start with first line
if viewport[0] != "A" {
t.Errorf("Viewport()[0] = %q, want 'A'", viewport[0])
}
})
t.Run("viewport scrolled down", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"},
ScrollOffset: 3,
}
// Terminal height 20 → max window = 15
viewport := co.Viewport(20)
// Should start at offset 3 (line "D")
if viewport[0] != "D" {
t.Errorf("Viewport()[0] = %q, want 'D' (offset 3)", viewport[0])
}
})
t.Run("viewport with different terminal heights", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('0' + (i % 10)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Small terminal (40 lines) → max window = 30
viewport := co.Viewport(40)
if len(viewport) != 30 {
t.Errorf("Viewport(40) returned %d lines, want 30", len(viewport))
}
// Large terminal (200 lines) → max window = 150, but only 100 lines available
viewport = co.Viewport(200)
if len(viewport) != 100 {
t.Errorf("Viewport(200) returned %d lines, want 100 (all available)", len(viewport))
}
})
t.Run("viewport at maximum scroll", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 2, // Showing lines from index 2 onwards
}
// Terminal height 20 → max window = 15 (but only 3 lines available from offset 2)
viewport := co.Viewport(20)
want := []string{"C", "D", "E"}
if len(viewport) != len(want) {
t.Errorf("Viewport() returned %d lines, want %d", len(viewport), len(want))
}
for i, line := range viewport {
if line != want[i] {
t.Errorf("Viewport()[%d] = %q, want %q", i, line, want[i])
}
}
})
}
// TestCommandOutputScrollDown tests the ScrollDown() method
func TestCommandOutputScrollDown(t *testing.T) {
t.Run("scroll down with space available", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Terminal height 100 → max window = 75
// Can scroll since 100 lines > 75 viewport
co.ScrollDown(100)
if co.ScrollOffset != 1 {
t.Errorf("ScrollOffset = %d after ScrollDown, want 1", co.ScrollOffset)
}
})
t.Run("scroll down multiple times", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Scroll down 5 times
for i := 0; i < 5; i++ {
co.ScrollDown(100)
}
if co.ScrollOffset != 5 {
t.Errorf("ScrollOffset = %d after 5 ScrollDown calls, want 5", co.ScrollOffset)
}
})
t.Run("cannot scroll past end of content", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 0,
}
// Terminal height 20 → max window = 15
// 5 lines fit entirely in viewport, should not scroll
co.ScrollDown(20)
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d, want 0 (should not scroll when content fits)", co.ScrollOffset)
}
})
t.Run("scroll down to maximum", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "J"},
ScrollOffset: 0,
}
// Terminal height 16 → max window = 12
// 10 lines total, can scroll down until offset + 12 >= 10
// Max offset = 10 - 12 = -2, but we can't go negative
// Actually: can scroll while (offset + 12) < 10
// So max offset before stopping = 10 - 12 = can't scroll at all? Let me recalculate
// offset=0: show lines 0-11 (but only 10 exist) → shows all 10
// Can't scroll since viewport > content
// Let's use different numbers: 20 lines, window of 12
lines := make([]string, 20)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co.Lines = lines
// Max scroll: offset + 12 < 20 → offset < 8 → max offset = 7
// But the function allows offset until (offset + 12) < 20
// So when offset = 8, offset + 12 = 20, not < 20, stops
for i := 0; i < 20; i++ {
co.ScrollDown(16)
}
// Maximum offset should be 8 (showing lines 8-19, which is 12 lines)
want := 8
if co.ScrollOffset != want {
t.Errorf("ScrollOffset = %d after scrolling to max, want %d", co.ScrollOffset, want)
}
})
t.Run("scroll down at maximum has no effect", func(t *testing.T) {
lines := make([]string, 20)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 8, // Already at max for height 16 (window 12)
}
co.ScrollDown(16)
if co.ScrollOffset != 8 {
t.Errorf("ScrollOffset = %d, want 8 (should not scroll past end)", co.ScrollOffset)
}
})
t.Run("scroll with different terminal heights", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
// Small terminal (height 40 → window 30)
co.ScrollDown(40)
if co.ScrollOffset != 1 {
t.Errorf("ScrollOffset = %d for height 40, want 1", co.ScrollOffset)
}
// Reset
co.ScrollOffset = 0
// Large terminal (height 200 → window 150)
co.ScrollDown(200)
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d for height 200, want 0 (content fits entirely)", co.ScrollOffset)
}
})
}
// TestCommandOutputScrollUp tests the ScrollUp() method
func TestCommandOutputScrollUp(t *testing.T) {
t.Run("scroll up from offset", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 3,
}
co.ScrollUp()
if co.ScrollOffset != 2 {
t.Errorf("ScrollOffset = %d after ScrollUp, want 2", co.ScrollOffset)
}
})
t.Run("scroll up multiple times", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 5,
}
for i := 0; i < 3; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 2 {
t.Errorf("ScrollOffset = %d after 3 ScrollUp calls, want 2", co.ScrollOffset)
}
})
t.Run("cannot scroll up past zero", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 0,
}
co.ScrollUp()
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d, want 0 (should not go negative)", co.ScrollOffset)
}
})
t.Run("scroll up to zero", func(t *testing.T) {
co := &CommandOutput{
Lines: []string{"A", "B", "C", "D", "E"},
ScrollOffset: 3,
}
// Scroll up 3 times to reach 0
for i := 0; i < 3; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling to top, want 0", co.ScrollOffset)
}
// Try scrolling up one more time
co.ScrollUp()
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling past top, want 0", co.ScrollOffset)
}
})
t.Run("scroll up from large offset", func(t *testing.T) {
lines := make([]string, 100)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 50,
}
co.ScrollUp()
if co.ScrollOffset != 49 {
t.Errorf("ScrollOffset = %d after ScrollUp, want 49", co.ScrollOffset)
}
})
}
// TestCommandOutputScrollingIntegration tests combined scrolling behavior
func TestCommandOutputScrollingIntegration(t *testing.T) {
t.Run("scroll down and up", func(t *testing.T) {
lines := make([]string, 50)
for i := range lines {
lines[i] = string(rune('A' + (i % 26)))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
termHeight := 40 // max window = 30
// Scroll down 5 times
for i := 0; i < 5; i++ {
co.ScrollDown(termHeight)
}
if co.ScrollOffset != 5 {
t.Errorf("ScrollOffset = %d after 5 down, want 5", co.ScrollOffset)
}
// Scroll up 2 times
for i := 0; i < 2; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 3 {
t.Errorf("ScrollOffset = %d after 2 up, want 3", co.ScrollOffset)
}
// Scroll back to top
for i := 0; i < 10; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling to top, want 0", co.ScrollOffset)
}
})
t.Run("scroll to bottom and back to top", func(t *testing.T) {
lines := make([]string, 30)
for i := range lines {
lines[i] = string(rune('A' + i))
}
co := &CommandOutput{
Lines: lines,
ScrollOffset: 0,
}
termHeight := 28 // max window = 21
// Scroll to bottom (max offset = 30 - 21 = 9)
for i := 0; i < 20; i++ {
co.ScrollDown(termHeight)
}
want := 9
if co.ScrollOffset != want {
t.Errorf("ScrollOffset = %d after scrolling to bottom, want %d", co.ScrollOffset, want)
}
// Verify viewport shows last lines
viewport := co.Viewport(termHeight)
if len(viewport) != 21 {
t.Errorf("Viewport length = %d, want 21", len(viewport))
}
if viewport[len(viewport)-1] != string(rune('A'+29)) {
t.Errorf("Last viewport line = %q, want %q", viewport[len(viewport)-1], string(rune('A'+29)))
}
// Scroll back to top
for i := 0; i < 20; i++ {
co.ScrollUp()
}
if co.ScrollOffset != 0 {
t.Errorf("ScrollOffset = %d after scrolling back to top, want 0", co.ScrollOffset)
}
})
}

View File

@ -60,9 +60,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// TODO: Any vim action should exit also // TODO: Any vim action should exit also
// Simple override for command output mode for now // Simple override for command output mode for now
if m.Mode() == core.CommandOutputMode { if m.Mode() == core.CommandOutputMode {
if msg.Type == tea.KeyEnter { switch msg.String() {
case "enter":
m.SetMode(core.NormalMode) m.SetMode(core.NormalMode)
m.SetCommandOutput(&core.CommandOutput{}) m.SetCommandOutput(&core.CommandOutput{})
case "j":
m.CommandOutput().ScrollDown(m.termHeight)
case "k":
m.CommandOutput().ScrollUp()
} }
} else { } else {
cmd = m.input.Handle(m, msg.String()) cmd = m.input.Handle(m, msg.String())

View File

@ -35,7 +35,7 @@ func (m Model) View() string {
// TODO: This is not idea, but it works for now // TODO: This is not idea, but it works for now
cmd := m.CommandOutput() cmd := m.CommandOutput()
if cmd != nil && cmd.IsActive() && !cmd.Inline && cmd.Height() > 0 { if cmd != nil && cmd.IsActive() && !cmd.Inline && cmd.Height() > 0 {
view = overlayCommandOutputWindow(view, cmd, styles, m.termWidth) view = overlayCommandOutputWindow(view, cmd, styles, m.termWidth, m.termHeight)
} }
return view return view
@ -319,7 +319,7 @@ func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool {
// overlayCommandOutputWindow: Draw the overlay of the command output window. This will override // overlayCommandOutputWindow: Draw the overlay of the command output window. This will override
// (overlay) the displayed content, so it should be used only when needed. // (overlay) the displayed content, so it should be used only when needed.
func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles style.Styles, termWidth int) string { func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles style.Styles, termWidth int, termHeight int) string {
// Safety check // Safety check
if cmd == nil { if cmd == nil {
return view return view
@ -336,7 +336,8 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty
title := styles.LineStyle.Render(cmd.Title) title := styles.LineStyle.Render(cmd.Title)
overlay = append(overlay, title) overlay = append(overlay, title)
} }
for _, l := range cmd.Lines { viewLines := cmd.Viewport(termHeight)
for _, l := range viewLines {
content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n")) content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n"))
overlay = append(overlay, content) overlay = append(overlay, content)
} }