gitstack

grindlemire/go-tui code browser

6.3 KB Go 188 lines 2026-08-06 · c0f865a raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
package tui

import (
	"strings"
	"testing"
)

// newInlineResizeTestApp constructs an inline-mode App on a MockTerminal.
// MockTerminal.Resize preserves content top-anchored (rows keep their
// positions, blank rows appear at the bottom), matching how terminals behave
// when the window grows and the inline widget's rows stay where they were.
func newInlineResizeTestApp(width, termHeight, inlineHeight int) (*App, *MockTerminal) {
	term := NewMockTerminal(width, termHeight)
	app := &App{
		terminal:       term,
		inlineHeight:   inlineHeight,
		inlineStartRow: termHeight - inlineHeight,
		inlineLayout:   newInlineLayoutState(termHeight - inlineHeight),
		buffer:         NewBuffer(width, inlineHeight),
		focus:          newFocusManager(),
		mounts:         newMountState(),
	}
	return app, term
}

// rowText returns the trimmed text content of a terminal row.
func rowText(term *MockTerminal, row int) string {
	lines := strings.Split(term.String(), "\n")
	if row < 0 || row >= len(lines) {
		return ""
	}
	return strings.TrimRight(lines[row], " ")
}

// TestInlineResize_TerminalGrowth_ClearsOldWidgetRows reproduces the resize
// ghost bug from issue #119: when the terminal grows taller, the full redraw
// after the ResizeEvent must erase the widget rows at the old (higher) start
// row, not just repaint at the new one.
func TestInlineResize_TerminalGrowth_ClearsOldWidgetRows(t *testing.T) {
	app, term := newInlineResizeTestApp(80, 30, 6)
	app.root = New(WithText("COMPOSER"))

	// Initial paint: widget occupies rows 24-29.
	app.needsFullRedraw = true
	app.renderFrame()
	if got := rowText(term, 24); got != "COMPOSER" {
		t.Fatalf("precondition: row 24 = %q, want %q", got, "COMPOSER")
	}

	// Terminal grows to 40 rows. Content stays top-anchored, so the old
	// widget band is still visible at rows 24-29.
	term.Resize(80, 40)
	app.Dispatch(ResizeEvent{Width: 80, Height: 40})
	app.renderFrame()

	// The widget must now be at rows 34-39.
	if got := rowText(term, 34); got != "COMPOSER" {
		t.Fatalf("row 34 = %q, want %q", got, "COMPOSER")
	}
	// The old band (rows 24-29) and the gap below it must be blank.
	for row := 24; row < 34; row++ {
		if got := rowText(term, row); got != "" {
			t.Fatalf("ghost row %d = %q, want blank", row, got)
		}
	}
}

// TestInlineResize_TerminalGrowth_RepeatedSteps mimics a resize drag: several
// growth steps between renders must clear every stale band, down to the
// earliest start row painted since the last full redraw.
func TestInlineResize_TerminalGrowth_RepeatedSteps(t *testing.T) {
	app, term := newInlineResizeTestApp(80, 30, 6)
	app.root = New(WithText("COMPOSER"))

	app.needsFullRedraw = true
	app.renderFrame()

	// Two growth events arrive before the next render (drag).
	term.Resize(80, 34)
	app.Dispatch(ResizeEvent{Width: 80, Height: 34})
	term.Resize(80, 42)
	app.Dispatch(ResizeEvent{Width: 80, Height: 42})
	app.renderFrame()

	if got := rowText(term, 36); got != "COMPOSER" {
		t.Fatalf("row 36 = %q, want %q", got, "COMPOSER")
	}
	for row := 24; row < 36; row++ {
		if got := rowText(term, row); got != "" {
			t.Fatalf("ghost row %d = %q, want blank", row, got)
		}
	}
}

// TestInlineResize_ShrinkBelowHeight_ClampsStartRow covers issue #122: a
// terminal shorter than the inline height must clamp the start row at 0, like
// the startup and resume paths already do, instead of going negative and
// producing negative cursor rows.
func TestInlineResize_ShrinkBelowHeight_ClampsStartRow(t *testing.T) {
	app, term := newInlineResizeTestApp(80, 8, 6)
	app.root = New(WithText("COMPOSER"))

	app.needsFullRedraw = true
	app.renderFrame()

	term.Resize(80, 4)
	app.Dispatch(ResizeEvent{Width: 80, Height: 4})

	if app.inlineStartRow != 0 {
		t.Fatalf("inlineStartRow = %d, want 0 (clamped)", app.inlineStartRow)
	}
	app.renderFrame()
	if _, cy := term.Cursor(); cy != 0 {
		t.Fatalf("clear start row = %d, want 0", cy)
	}
}

// TestExitAlternateScreen_TerminalShorterThanInlineHeight_ClampsStartRow
// covers the last unclamped start-row computation: a resize while in alt
// screen only resizes the buffer, so the exit path recomputes the start row
// and must clamp it at 0 like every other path.
func TestExitAlternateScreen_TerminalShorterThanInlineHeight_ClampsStartRow(t *testing.T) {
	app, term := newInlineResizeTestApp(80, 30, 6)

	if err := app.EnterAlternateScreen(); err != nil {
		t.Fatal(err)
	}
	term.Resize(80, 4)
	app.Dispatch(ResizeEvent{Width: 80, Height: 4})
	if err := app.ExitAlternateScreen(); err != nil {
		t.Fatal(err)
	}

	if app.inlineStartRow != 0 {
		t.Fatalf("inlineStartRow = %d, want 0 (clamped)", app.inlineStartRow)
	}
}

// TestInlineResize_ShrinkBelowHeightThenGrow_ClampsClearRow pins the tiny
// terminal round trip: shrinking below the inline height then growing back
// must clear from row 0, never a negative cursor row.
func TestInlineResize_ShrinkBelowHeightThenGrow_ClampsClearRow(t *testing.T) {
	app, term := newInlineResizeTestApp(80, 8, 6)
	app.root = New(WithText("COMPOSER"))

	app.needsFullRedraw = true
	app.renderFrame()

	// Shrink below the inline height: the start row clamps at 0.
	term.Resize(80, 4)
	app.Dispatch(ResizeEvent{Width: 80, Height: 4})
	// Grow back: the clamped row is recorded as the clear marker.
	term.Resize(80, 8)
	app.Dispatch(ResizeEvent{Width: 80, Height: 8})
	app.renderFrame()

	if _, cy := term.Cursor(); cy != 0 {
		t.Fatalf("clear start row = %d, want 0 (clamped)", cy)
	}
}

// TestInlineResize_TerminalShrink_UnchangedBehavior pins the shrink path: the
// new start row is above the old one, so clearing from the new start row
// already covers the old band. No extra clearing should occur above it.
func TestInlineResize_TerminalShrink_UnchangedBehavior(t *testing.T) {
	app, term := newInlineResizeTestApp(80, 40, 6)
	app.root = New(WithText("COMPOSER"))

	app.needsFullRedraw = true
	app.renderFrame()
	if got := rowText(term, 34); got != "COMPOSER" {
		t.Fatalf("precondition: row 34 = %q, want %q", got, "COMPOSER")
	}

	// History line above the shrunken viewport must survive the redraw.
	term.SetCell(0, 23, NewCell('H', NewStyle()))

	term.Resize(80, 30)
	app.Dispatch(ResizeEvent{Width: 80, Height: 30})
	app.renderFrame()

	if got := rowText(term, 24); got != "COMPOSER" {
		t.Fatalf("row 24 = %q, want %q", got, "COMPOSER")
	}
	if got := rowText(term, 23); got != "H" {
		t.Fatalf("history row 23 = %q, want %q (must not be cleared)", got, "H")
	}
}