From 5ff473d0d9d7679bfc352c9753ca531fbdaca9cb Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Thu, 19 Mar 2026 14:32:21 -0700 Subject: [PATCH] feat: implementing scrolling cmd output window: tested --- internal/core/command.go | 40 ++- internal/core/command_test.go | 588 ++++++++++++++++++++++++++++++++++ internal/editor/update.go | 7 +- internal/editor/view.go | 7 +- 4 files changed, 637 insertions(+), 5 deletions(-) create mode 100644 internal/core/command_test.go diff --git a/internal/core/command.go b/internal/core/command.go index b378789..ed989df 100644 --- a/internal/core/command.go +++ b/internal/core/command.go @@ -1,6 +1,8 @@ package core -import "strings" +import ( + "strings" +) const CommandOutputExitMessage = "Press ENTER to continue" @@ -37,3 +39,39 @@ func (c *CommandOutput) Height() int { func (c *CommandOutput) IsActive() bool { 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-- + } +} diff --git a/internal/core/command_test.go b/internal/core/command_test.go new file mode 100644 index 0000000..9aa0bf8 --- /dev/null +++ b/internal/core/command_test.go @@ -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) + } + }) +} diff --git a/internal/editor/update.go b/internal/editor/update.go index 5797d36..54d9f5a 100644 --- a/internal/editor/update.go +++ b/internal/editor/update.go @@ -60,9 +60,14 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // TODO: Any vim action should exit also // Simple override for command output mode for now if m.Mode() == core.CommandOutputMode { - if msg.Type == tea.KeyEnter { + switch msg.String() { + case "enter": m.SetMode(core.NormalMode) m.SetCommandOutput(&core.CommandOutput{}) + case "j": + m.CommandOutput().ScrollDown(m.termHeight) + case "k": + m.CommandOutput().ScrollUp() } } else { cmd = m.input.Handle(m, msg.String()) diff --git a/internal/editor/view.go b/internal/editor/view.go index 6ead1e7..144b7ba 100644 --- a/internal/editor/view.go +++ b/internal/editor/view.go @@ -35,7 +35,7 @@ func (m Model) View() string { // TODO: This is not idea, but it works for now cmd := m.CommandOutput() 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 @@ -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 // (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 if cmd == nil { return view @@ -336,7 +336,8 @@ func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, styles sty title := styles.LineStyle.Render(cmd.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")) overlay = append(overlay, content) }