terminal_focus_test.go RawHistoryFolder

1package daemon
2
3import (
4	"testing"
5
6	"github.com/stretchr/testify/require"
7
8	"github.com/bnema/vev/internal/domain"
9	"github.com/bnema/vev/internal/protocol"
10	"github.com/bnema/vev/internal/usecase/ui"
11)
12
13// TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt pins the focus rule: a
14// client whose terminal window lost focus shows the bell of its own tab but
15// never clears it, while a focused client, or one whose terminal never
16// reports focus, clears it on its visible paint as before.
17func TestPaintAcksAttentionOnlyWhenTheClientMaySeeIt(t *testing.T) {
18	tests := []struct {
19		name      string
20		focus     domain.TerminalFocus
21		wantAcked bool
22	}{
23		{name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
24		{name: "focused client acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
25		{name: "unfocused client leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
26	}
27	for _, tt := range tests {
28		t.Run(tt.name, func(t *testing.T) {
29			d, sess, ac, sends, releases := newManualTabSession(t, 1)
30			defer releases[0]()
31			selectTestAttachmentTab(sess, 0)
32			ac.setTerminalFocus(tt.focus)
33			d.noteAttention(sess, sess.tabs[0])
34
35			d.setAttentionFrame(1)
36			d.paint(sess, ac, true, nil)
37			require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the tab bar shows the bell")
38
39			sess.mu.Lock()
40			defer sess.mu.Unlock()
41			require.Equal(t, !tt.wantAcked, sess.tabs[0].attention)
42		})
43	}
44}
45
46// TestRegainedFocusAcknowledgesTheVisibleTab pins the other half: the bell a
47// hidden client left pending clears once that client's window is focused and
48// paints again.
49func TestRegainedFocusAcknowledgesTheVisibleTab(t *testing.T) {
50	d, sess, ac, sends, releases := newManualTabSession(t, 1)
51	defer releases[0]()
52	selectTestAttachmentTab(sess, 0)
53	ac.setTerminalFocus(domain.TerminalFocusUnfocused)
54	d.noteAttention(sess, sess.tabs[0])
55	d.setAttentionFrame(1)
56	d.paint(sess, ac, true, nil)
57	mustOutputData(t, sends)
58	require.True(t, sess.anyAttention(), "hidden client keeps the bell")
59
60	require.True(t, ac.setTerminalFocus(domain.TerminalFocusFocused))
61	require.False(t, ac.setTerminalFocus(domain.TerminalFocusFocused), "a repeated report is no change")
62	d.paint(sess, ac, true, nil)
63	mustOutputData(t, sends)
64	require.False(t, sess.anyAttention(), "focused client clears the bell")
65}
66
67// TestAttachDeclaredFocusGuardsTheFirstPaint pins that the focus declared in
68// Hello is the attachment's focus from its first paint, so a hidden window
69// that opens an attachment never clears the bell of the tab it lands on.
70func TestAttachDeclaredFocusGuardsTheFirstPaint(t *testing.T) {
71	tests := []struct {
72		name      string
73		focus     domain.TerminalFocus
74		wantAcked bool
75	}{
76		{name: "unknown focus acknowledges as before", focus: domain.TerminalFocusUnknown, wantAcked: true},
77		{name: "focused window acknowledges", focus: domain.TerminalFocusFocused, wantAcked: true},
78		{name: "unfocused window leaves the bell", focus: domain.TerminalFocusUnfocused, wantAcked: false},
79	}
80	for _, tt := range tests {
81		t.Run(tt.name, func(t *testing.T) {
82			d, sess, first, _, releases := newManualTabSession(t, 1)
83			defer releases[0]()
84			first.setTerminalFocus(domain.TerminalFocusUnfocused)
85			d.noteAttention(sess, sess.tabs[0])
86
87			tr, sends := newCapturingTransport(t)
88			peer, err := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{terminalFocus: tt.focus})
89			require.NoError(t, err)
90			require.Equal(t, tt.focus, peer.terminalFocus())
91			require.True(t, sess.switchAttachmentTab(peer, 0))
92			d.setAttentionFrame(1)
93			d.paint(sess, peer, true, nil)
94			mustOutputData(t, sends)
95			require.Equal(t, !tt.wantAcked, sess.anyAttention())
96		})
97	}
98}
99
100// TestFocusReportDrawsTheBellBeforeAcknowledging pins the routed focus report:
101// only a gain of focus repaints, and the bell a focused window finds on its
102// tab is drawn for one pulse before a later paint acknowledges it.
103func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) {
104	d, sess, ac, sends, releases := newManualTabSession(t, 1)
105	defer releases[0]()
106	selectTestAttachmentTab(sess, 0)
107	effect := &attachmentEffect{attachmentCapability: attachmentCapability{sess: sess, ac: ac}}
108
109	d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusUnfocused})
110	require.Equal(t, domain.TerminalFocusUnfocused, ac.terminalFocus())
111	require.Empty(t, sends, "losing focus does not repaint")
112	d.noteAttention(sess, sess.tabs[0])
113
114	d.setAttentionFrame(0)
115	d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
116	require.Equal(t, domain.TerminalFocusFocused, ac.terminalFocus())
117	mustOutputData(t, sends)
118	require.True(t, sess.anyAttention(), "the bell survives the blank pulse frame")
119
120	d.setAttentionFrame(1)
121	d.paint(sess, ac, false, nil)
122	require.Contains(t, string(mustOutputData(t, sends)), string(ui.AttentionGlyph), "the focused window draws the bell")
123	require.False(t, sess.anyAttention(), "the drawn bell is acknowledged")
124
125	// The acknowledgement repaints every client; drain those frames.
126	for len(sends) > 0 {
127		<-sends
128	}
129	d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: domain.TerminalFocusFocused})
130	require.Empty(t, sends, "an unchanged focus does not repaint")
131}
132
133// TestFocusGainClaimsSharedGeometry pins that a client whose terminal window
134// gains focus becomes the shared geometry claimant, while losing focus leaves
135// the current claimant in place and a repeated report never claims again.
136func TestFocusGainClaimsSharedGeometry(t *testing.T) {
137	tests := []struct {
138		name        string
139		prior       domain.TerminalFocus
140		focus       domain.TerminalFocus
141		wantClaimed bool
142	}{
143		{name: "unknown to focused claims", prior: domain.TerminalFocusUnknown, focus: domain.TerminalFocusFocused, wantClaimed: true},
144		{name: "unfocused to focused claims", prior: domain.TerminalFocusUnfocused, focus: domain.TerminalFocusFocused, wantClaimed: true},
145		{name: "repeated focused report does not claim", prior: domain.TerminalFocusFocused, focus: domain.TerminalFocusFocused, wantClaimed: false},
146		{name: "focus loss keeps claimant", prior: domain.TerminalFocusUnknown, focus: domain.TerminalFocusUnfocused, wantClaimed: false},
147	}
148	for _, tt := range tests {
149		t.Run(tt.name, func(t *testing.T) {
150			d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
151			first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
152			first.setTerminalFocus(tt.prior)
153			second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
154			require.True(t, sess.geometry.reconcile(d, sess, second))
155			requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38})
156
157			token := sess.captureAttachmentCapability(first, first.transport())
158			first.installTestAttachmentCapability(token)
159			effect, admitted := first.beginAttachmentEffect(token)
160			require.True(t, admitted)
161			d.applyTerminalFocusForAttachment(effect, protocol.TerminalFocus{Focus: tt.focus})
162			effect.End()
163
164			if tt.wantClaimed {
165				requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22})
166				return
167			}
168			requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38})
169		})
170	}
171}
172
173// TestUnfocusedClaimDoesNotTakeGeometry pins the claim ranking: a claim from
174// an attachment that reported losing focus (a resumed or tiled background
175// window) never displaces one that may be seen, but still wins once it is the
176// only candidate. Without focus reports the newest claim wins as before.
177func TestUnfocusedClaimDoesNotTakeGeometry(t *testing.T) {
178	tests := []struct {
179		name       string
180		firstFocus domain.TerminalFocus
181		lateFocus  domain.TerminalFocus
182		wantLate   bool
183	}{
184		{name: "unfocused late claim loses to focused", firstFocus: domain.TerminalFocusFocused, lateFocus: domain.TerminalFocusUnfocused, wantLate: false},
185		{name: "unfocused late claim loses to unknown", firstFocus: domain.TerminalFocusUnknown, lateFocus: domain.TerminalFocusUnfocused, wantLate: false},
186		{name: "unknown focus keeps newest-claim rule", firstFocus: domain.TerminalFocusUnknown, lateFocus: domain.TerminalFocusUnknown, wantLate: true},
187		{name: "focused late claim wins", firstFocus: domain.TerminalFocusUnfocused, lateFocus: domain.TerminalFocusFocused, wantLate: true},
188	}
189	for _, tt := range tests {
190		t.Run(tt.name, func(t *testing.T) {
191			d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
192			first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
193			first.setTerminalFocus(tt.firstFocus)
194			require.True(t, sess.geometry.reconcile(d, sess, first))
195			requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22})
196
197			late := &attachedClient{output: newOutputStateStream()}
198			late.tr, _ = newCapturingTransport(t)
199			late.output.attachment = late
200			late.initOverlays()
201			late.setGeometry(domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
202			late.setTerminalFocus(tt.lateFocus)
203			late.setSession(sess)
204			require.True(t, sess.registerAttachment(late)) // attach and resume claim here
205			sess.geometry.reconcile(d, sess, late)         // as a client Resize does
206
207			if !tt.wantLate {
208				requireMultiplexGeometry(t, sess, first, domain.Size{Cols: 80, Rows: 22})
209				// Once the seen claimant leaves, the unfocused claim is the only one.
210				d.clientGone(sess, first, first.transport(), true)
211				sess.geometry.reconcile(d, sess, nil)
212			}
213			requireMultiplexGeometry(t, sess, late, domain.Size{Cols: 120, Rows: 38})
214		})
215	}
216}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close