gitstack

grindlemire/go-tui code browser

4.9 KB Go 191 lines 2026-08-06 · 3fe52e1 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
189
190
191
package tui

import (
	"testing"
)

// syncRecordingTerminal embeds MockTerminal and records the order of sync
// update markers relative to output operations.
type syncRecordingTerminal struct {
	*MockTerminal
	ops []string
}

func newSyncRecordingTerminal(width, height int) *syncRecordingTerminal {
	return &syncRecordingTerminal{MockTerminal: NewMockTerminal(width, height)}
}

func (s *syncRecordingTerminal) BeginSyncUpdate() {
	s.ops = append(s.ops, "begin")
}

func (s *syncRecordingTerminal) EndSyncUpdate() {
	s.ops = append(s.ops, "end")
}

func (s *syncRecordingTerminal) Flush(changes []CellChange) {
	s.ops = append(s.ops, "flush")
	s.MockTerminal.Flush(changes)
}

func (s *syncRecordingTerminal) SetCursor(x, y int) {
	s.ops = append(s.ops, "cursor")
	s.MockTerminal.SetCursor(x, y)
}

func (s *syncRecordingTerminal) Clear() {
	s.ops = append(s.ops, "clear")
	s.MockTerminal.Clear()
}

func (s *syncRecordingTerminal) ClearToEnd() {
	s.ops = append(s.ops, "clearToEnd")
	s.MockTerminal.ClearToEnd()
}

// assertWrapped checks that ops contain exactly one begin and one end, that
// begin comes first, end comes last, and at least one output op sits between.
func assertWrapped(t *testing.T, ops []string) {
	t.Helper()
	if len(ops) < 3 {
		t.Fatalf("ops = %v, want begin + output + end", ops)
	}
	if ops[0] != "begin" {
		t.Fatalf("first op = %q, want %q (ops: %v)", ops[0], "begin", ops)
	}
	if ops[len(ops)-1] != "end" {
		t.Fatalf("last op = %q, want %q (ops: %v)", ops[len(ops)-1], "end", ops)
	}
	begins, ends := 0, 0
	for _, op := range ops {
		switch op {
		case "begin":
			begins++
		case "end":
			ends++
		}
	}
	if begins != 1 || ends != 1 {
		t.Fatalf("begin/end counts = %d/%d, want 1/1 (ops: %v)", begins, ends, ops)
	}
}

func TestRenderFrame_FullScreen_WrapsOutputInSyncUpdate(t *testing.T) {
	term := newSyncRecordingTerminal(80, 24)
	app := &App{
		terminal: term,
		buffer:   NewBuffer(80, 24),
		focus:    newFocusManager(),
		mounts:   newMountState(),
		root:     New(WithText("hello")),
	}
	app.needsFullRedraw = true

	app.renderFrame()

	assertWrapped(t, term.ops)
}

func TestRenderFrame_InlineMode_WrapsOutputInSyncUpdate(t *testing.T) {
	term := newSyncRecordingTerminal(80, 24)
	app := &App{
		terminal:       term,
		buffer:         NewBuffer(80, 6),
		inlineHeight:   6,
		inlineStartRow: 18,
		inlineLayout:   newInlineLayoutState(18),
		focus:          newFocusManager(),
		mounts:         newMountState(),
		root:           New(WithText("hello")),
	}
	app.needsFullRedraw = true

	app.renderFrame()

	assertWrapped(t, term.ops)
}

func TestAppRenderFull_WrapsOutputInSyncUpdate(t *testing.T) {
	term := newSyncRecordingTerminal(80, 24)
	app := &App{
		terminal: term,
		buffer:   NewBuffer(80, 24),
		focus:    newFocusManager(),
		mounts:   newMountState(),
		root:     New(WithText("hello")),
	}

	app.RenderFull()

	assertWrapped(t, term.ops)
}

// TestRenderFrame_Resize_AllOutputInsideSyncWindow pins that a resize frame
// (buffer/terminal size mismatch) emits every terminal write inside the sync
// window. The screen clear on resize used to run before the window opened,
// flashing blank on exactly the frame type synchronized updates target.
func TestRenderFrame_Resize_AllOutputInsideSyncWindow(t *testing.T) {
	term := newSyncRecordingTerminal(100, 30)
	app := &App{
		terminal: term,
		buffer:   NewBuffer(80, 24), // stale size: forces the resize branch
		focus:    newFocusManager(),
		mounts:   newMountState(),
		root:     New(WithText("hello")),
	}

	app.renderFrame()

	assertWrapped(t, term.ops)
}

// TestRenderFrame_SyncUpdateEndsOnPanic pins the panic-safety contract: a
// panicking postRenderHook must not leave the terminal stuck in a
// synchronized update block, or the screen freezes on supporting terminals.
func TestRenderFrame_SyncUpdateEndsOnPanic(t *testing.T) {
	term := newSyncRecordingTerminal(80, 24)
	app := &App{
		terminal: term,
		buffer:   NewBuffer(80, 24),
		focus:    newFocusManager(),
		mounts:   newMountState(),
		root:     New(WithText("hello")),
		postRenderHook: func() {
			panic("hook failure")
		},
	}
	app.needsFullRedraw = true

	func() {
		defer func() {
			if recover() == nil {
				t.Fatal("expected the hook panic to propagate")
			}
		}()
		app.renderFrame()
	}()

	if len(term.ops) == 0 || term.ops[len(term.ops)-1] != "end" {
		t.Fatalf("last op = %v, want trailing %q even on panic", term.ops, "end")
	}
}

// TestRenderFrame_PlainTerminal_NoSyncUpdate pins that terminals without the
// sync update methods (like MockTerminal itself) render without them.
func TestRenderFrame_PlainTerminal_NoSyncUpdate(t *testing.T) {
	term := NewMockTerminal(80, 24)
	app := &App{
		terminal: term,
		buffer:   NewBuffer(80, 24),
		focus:    newFocusManager(),
		mounts:   newMountState(),
		root:     New(WithText("hello")),
	}
	app.needsFullRedraw = true

	app.renderFrame()

	if got := rowText(term, 0); got != "hello" {
		t.Fatalf("row 0 = %q, want %q", got, "hello")
	}
}