detached ab824cf

fix(focus): keep geometry on the focused window when a background window claims it

commit
ab824cfd0d354d3d05aaf5b4aa45e192d6aa6dd7
author
brice · · 2026-09-26 08:25 UTC
committer
brice · · 2026-09-26 08:37 UTC
parent
27972fd
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +96 -13
M internal/usecase/daemon/session_geometry.go +42 -7
@@ -29,8 +29,23 @@ type sharedPTYGeometry struct {
2929 applied domain.Geometry // mu
3030 }
3131
32-// latestValidClaimLocked selects the most recent valid remaining Attachment
33-// claim. The caller holds both session.mu and g.mu.
32+// claimOutranks reports whether claim a wins over b. A claim from an
33+// attachment whose terminal reported losing focus never wins over one that may
34+// be seen, so a background window cannot take geometry from the focused one.
35+// Otherwise the newest claim wins, as when no terminal reports focus.
36+func claimOutranks(a *sharedPTYClaim, aOrder uint64, b *sharedPTYClaim, bOrder uint64) bool {
37+ if b == nil {
38+ return true
39+ }
40+ aSeen, bSeen := a.attachment.terminalFocus().MaySee(), b.attachment.terminalFocus().MaySee()
41+ if aSeen != bSeen {
42+ return aSeen
43+ }
44+ return a.sequence > b.sequence || (a.sequence == b.sequence && aOrder > bOrder)
45+}
46+
47+// latestValidClaimLocked selects the winning valid remaining Attachment claim
48+// (see claimOutranks). The caller holds both session.mu and g.mu.
3449 func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *attachedClient) *sharedPTYClaim {
3550 if g == nil || core == nil {
3651 return nil
@@ -46,8 +61,7 @@ func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *a
4661 continue
4762 }
4863 order := core.attachmentOrder[attachment]
49- if latest == nil || claim.sequence > latest.sequence ||
50- (claim.sequence == latest.sequence && order > latestOrder) {
64+ if claimOutranks(claim, order, latest, latestOrder) {
5165 latest = claim
5266 latestOrder = order
5367 }
@@ -73,8 +87,10 @@ func (g *sharedPTYGeometry) refreshLocked(core *sessionCore) {
7387 g.latest.Store(g.latestValidClaimLocked(core, nil))
7488 }
7589
76-// claimLocked publishes a new immutable claim. The caller holds session.mu;
77-// claim publication then follows the session.mu -> geometry.mu order.
90+// claimLocked records a new immutable claim and publishes the winning claim,
91+// which is the new one unless an unfocused attachment made it while another
92+// may be seen. The caller holds session.mu; claim publication then follows the
93+// session.mu -> geometry.mu order.
7894 func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedClient, geometry domain.Geometry) *sharedPTYClaim {
7995 if g == nil || core == nil || attachment == nil || !geometry.Valid() {
8096 return nil
@@ -91,10 +107,29 @@ func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedC
91107 sequence: g.sequence,
92108 }
93109 g.claims[attachment] = claim
94- g.latest.Store(claim)
110+ if !g.currentHidesLocked(core, claim) {
111+ g.latest.Store(claim)
112+ }
95113 return claim
96114 }
97115
116+// currentHidesLocked reports whether the published claim must stay ahead of a
117+// newer claim: the newer one comes from an unfocused window while the current
118+// one is live and may be seen. The caller holds session.mu and g.mu.
119+func (g *sharedPTYGeometry) currentHidesLocked(core *sessionCore, claim *sharedPTYClaim) bool {
120+ current := g.latest.Load()
121+ if current == nil || current.attachment == claim.attachment {
122+ return false
123+ }
124+ if g.claims[current.attachment] != current {
125+ return false // the current claim was superseded or released
126+ }
127+ if _, registered := core.attachments[current.attachment]; !registered {
128+ return false
129+ }
130+ return !claim.attachment.terminalFocus().MaySee() && current.attachment.terminalFocus().MaySee()
131+}
132+
98133 // removeAttachmentLocked removes the exact Attachment claim and republishes
99134 // the most recent valid remaining claimant. The caller holds session.mu.
100135 func (g *sharedPTYGeometry) removeAttachmentLocked(core *sessionCore, attachment *attachedClient) {
@@ -29,8 +29,23 @@ type sharedPTYGeometry struct {
29 applied domain.Geometry // mu29 applied domain.Geometry // mu
30 }30 }
31 31
32-// latestValidClaimLocked selects the most recent valid remaining Attachment32+// claimOutranks reports whether claim a wins over b. A claim from an
33-// claim. The caller holds both session.mu and g.mu.33+// attachment whose terminal reported losing focus never wins over one that may
34+// be seen, so a background window cannot take geometry from the focused one.
35+// Otherwise the newest claim wins, as when no terminal reports focus.
36+func claimOutranks(a *sharedPTYClaim, aOrder uint64, b *sharedPTYClaim, bOrder uint64) bool {
37+ if b == nil {
38+ return true
39+ }
40+ aSeen, bSeen := a.attachment.terminalFocus().MaySee(), b.attachment.terminalFocus().MaySee()
41+ if aSeen != bSeen {
42+ return aSeen
43+ }
44+ return a.sequence > b.sequence || (a.sequence == b.sequence && aOrder > bOrder)
45+}
46+
47+// latestValidClaimLocked selects the winning valid remaining Attachment claim
48+// (see claimOutranks). The caller holds both session.mu and g.mu.
34 func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *attachedClient) *sharedPTYClaim {49 func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *attachedClient) *sharedPTYClaim {
35 if g == nil || core == nil {50 if g == nil || core == nil {
36 return nil51 return nil
@@ -46,8 +61,7 @@ func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *a
46 continue61 continue
47 }62 }
48 order := core.attachmentOrder[attachment]63 order := core.attachmentOrder[attachment]
49- if latest == nil || claim.sequence > latest.sequence ||64+ if claimOutranks(claim, order, latest, latestOrder) {
50- (claim.sequence == latest.sequence && order > latestOrder) {
51 latest = claim65 latest = claim
52 latestOrder = order66 latestOrder = order
53 }67 }
@@ -73,8 +87,10 @@ func (g *sharedPTYGeometry) refreshLocked(core *sessionCore) {
73 g.latest.Store(g.latestValidClaimLocked(core, nil))87 g.latest.Store(g.latestValidClaimLocked(core, nil))
74 }88 }
75 89
76-// claimLocked publishes a new immutable claim. The caller holds session.mu;90+// claimLocked records a new immutable claim and publishes the winning claim,
77-// claim publication then follows the session.mu -> geometry.mu order.91+// which is the new one unless an unfocused attachment made it while another
92+// may be seen. The caller holds session.mu; claim publication then follows the
93+// session.mu -> geometry.mu order.
78 func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedClient, geometry domain.Geometry) *sharedPTYClaim {94 func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedClient, geometry domain.Geometry) *sharedPTYClaim {
79 if g == nil || core == nil || attachment == nil || !geometry.Valid() {95 if g == nil || core == nil || attachment == nil || !geometry.Valid() {
80 return nil96 return nil
@@ -91,10 +107,29 @@ func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedC
91 sequence: g.sequence,107 sequence: g.sequence,
92 }108 }
93 g.claims[attachment] = claim109 g.claims[attachment] = claim
94- g.latest.Store(claim)110+ if !g.currentHidesLocked(core, claim) {
111+ g.latest.Store(claim)
112+ }
95 return claim113 return claim
96 }114 }
97 115
116+// currentHidesLocked reports whether the published claim must stay ahead of a
117+// newer claim: the newer one comes from an unfocused window while the current
118+// one is live and may be seen. The caller holds session.mu and g.mu.
119+func (g *sharedPTYGeometry) currentHidesLocked(core *sessionCore, claim *sharedPTYClaim) bool {
120+ current := g.latest.Load()
121+ if current == nil || current.attachment == claim.attachment {
122+ return false
123+ }
124+ if g.claims[current.attachment] != current {
125+ return false // the current claim was superseded or released
126+ }
127+ if _, registered := core.attachments[current.attachment]; !registered {
128+ return false
129+ }
130+ return !claim.attachment.terminalFocus().MaySee() && current.attachment.terminalFocus().MaySee()
131+}
132+
98 // removeAttachmentLocked removes the exact Attachment claim and republishes133 // removeAttachmentLocked removes the exact Attachment claim and republishes
99 // the most recent valid remaining claimant. The caller holds session.mu.134 // the most recent valid remaining claimant. The caller holds session.mu.
100 func (g *sharedPTYGeometry) removeAttachmentLocked(core *sessionCore, attachment *attachedClient) {135 func (g *sharedPTYGeometry) removeAttachmentLocked(core *sessionCore, attachment *attachedClient) {
M internal/usecase/daemon/terminal_focus.go +2 -3
@@ -51,9 +51,8 @@ func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, messa
5151 // the focus lets any paint acknowledge.
5252 effect.sess.markVisibleAttention(effect.ac)
5353 effect.ac.setTerminalFocus(message.Focus)
54- if message.Focus == domain.TerminalFocusFocused && effect.current() {
55- effect.sess.geometry.reconcileAndInvalidate(d, effect.sess, effect.ac, "terminal_focus.go")
56- }
54+ // The caller admitted this effect, and only Focused reaches here.
55+ effect.sess.geometry.reconcileAndInvalidate(d, effect.sess, effect.ac, "terminal_focus.go")
5756 d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go")
5857 }
5958
@@ -51,9 +51,8 @@ func (d *Daemon) applyTerminalFocusForAttachment(effect *attachmentEffect, messa
51 // the focus lets any paint acknowledge.51 // the focus lets any paint acknowledge.
52 effect.sess.markVisibleAttention(effect.ac)52 effect.sess.markVisibleAttention(effect.ac)
53 effect.ac.setTerminalFocus(message.Focus)53 effect.ac.setTerminalFocus(message.Focus)
54- if message.Focus == domain.TerminalFocusFocused && effect.current() {54+ // The caller admitted this effect, and only Focused reaches here.
55- effect.sess.geometry.reconcileAndInvalidate(d, effect.sess, effect.ac, "terminal_focus.go")55+ effect.sess.geometry.reconcileAndInvalidate(d, effect.sess, effect.ac, "terminal_focus.go")
56- }
57 d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go")56 d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go")
58 }57 }
59 58
M internal/usecase/daemon/terminal_focus_test.go +52 -3
@@ -132,20 +132,24 @@ func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) {
132132
133133 // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window
134134 // gains focus becomes the shared geometry claimant, while losing focus leaves
135-// the current claimant in place.
135+// the current claimant in place and a repeated report never claims again.
136136 func TestFocusGainClaimsSharedGeometry(t *testing.T) {
137137 tests := []struct {
138138 name string
139+ prior domain.TerminalFocus
139140 focus domain.TerminalFocus
140141 wantClaimed bool
141142 }{
142- {name: "focus gain claims geometry", focus: domain.TerminalFocusFocused, wantClaimed: true},
143- {name: "focus loss keeps claimant", focus: domain.TerminalFocusUnfocused, wantClaimed: false},
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},
144147 }
145148 for _, tt := range tests {
146149 t.Run(tt.name, func(t *testing.T) {
147150 d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
148151 first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
152+ first.setTerminalFocus(tt.prior)
149153 second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
150154 require.True(t, sess.geometry.reconcile(d, sess, second))
151155 requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38})
@@ -165,3 +169,48 @@ func TestFocusGainClaimsSharedGeometry(t *testing.T) {
165169 })
166170 }
167171 }
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.
177+func 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+}
@@ -132,20 +132,24 @@ func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) {
132 132
133 // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window133 // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window
134 // gains focus becomes the shared geometry claimant, while losing focus leaves134 // gains focus becomes the shared geometry claimant, while losing focus leaves
135-// the current claimant in place.135+// the current claimant in place and a repeated report never claims again.
136 func TestFocusGainClaimsSharedGeometry(t *testing.T) {136 func TestFocusGainClaimsSharedGeometry(t *testing.T) {
137 tests := []struct {137 tests := []struct {
138 name string138 name string
139+ prior domain.TerminalFocus
139 focus domain.TerminalFocus140 focus domain.TerminalFocus
140 wantClaimed bool141 wantClaimed bool
141 }{142 }{
142- {name: "focus gain claims geometry", focus: domain.TerminalFocusFocused, wantClaimed: true},143+ {name: "unknown to focused claims", prior: domain.TerminalFocusUnknown, focus: domain.TerminalFocusFocused, wantClaimed: true},
143- {name: "focus loss keeps claimant", focus: domain.TerminalFocusUnfocused, wantClaimed: false},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},
144 }147 }
145 for _, tt := range tests {148 for _, tt := range tests {
146 t.Run(tt.name, func(t *testing.T) {149 t.Run(tt.name, func(t *testing.T) {
147 d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})150 d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
148 first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})151 first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
152+ first.setTerminalFocus(tt.prior)
149 second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})153 second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}})
150 require.True(t, sess.geometry.reconcile(d, sess, second))154 require.True(t, sess.geometry.reconcile(d, sess, second))
151 requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38})155 requireMultiplexGeometry(t, sess, second, domain.Size{Cols: 120, Rows: 38})
@@ -165,3 +169,48 @@ func TestFocusGainClaimsSharedGeometry(t *testing.T) {
165 })169 })
166 }170 }
167 }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.
177+func 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