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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 29 | 29 | applied domain.Geometry // mu | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 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. | |
| 34 | 49 | func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *attachedClient) *sharedPTYClaim { | |
| 35 | 50 | if g == nil || core == nil { | |
| 36 | 51 | return nil | |
| @@ -46,8 +61,7 @@ func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *a | |||
| 46 | 61 | continue | |
| 47 | 62 | } | |
| 48 | 63 | 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) { | |
| 51 | 65 | latest = claim | |
| 52 | 66 | latestOrder = order | |
| 53 | 67 | } | |
| @@ -73,8 +87,10 @@ func (g *sharedPTYGeometry) refreshLocked(core *sessionCore) { | |||
| 73 | 87 | g.latest.Store(g.latestValidClaimLocked(core, nil)) | |
| 74 | 88 | } | |
| 75 | 89 | ||
| 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. | |
| 78 | 94 | func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedClient, geometry domain.Geometry) *sharedPTYClaim { | |
| 79 | 95 | if g == nil || core == nil || attachment == nil || !geometry.Valid() { | |
| 80 | 96 | return nil | |
| @@ -91,10 +107,29 @@ func (g *sharedPTYGeometry) claimLocked(core *sessionCore, attachment *attachedC | |||
| 91 | 107 | sequence: g.sequence, | |
| 92 | 108 | } | |
| 93 | 109 | g.claims[attachment] = claim | |
| 94 | - | g.latest.Store(claim) | |
| 110 | + | if !g.currentHidesLocked(core, claim) { | |
| 111 | + | g.latest.Store(claim) | |
| 112 | + | } | |
| 95 | 113 | 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 | 133 | // removeAttachmentLocked removes the exact Attachment claim and republishes | |
| 99 | 134 | // the most recent valid remaining claimant. The caller holds session.mu. | |
| 100 | 135 | func (g *sharedPTYGeometry) removeAttachmentLocked(core *sessionCore, attachment *attachedClient) { | |
| @@ -29,8 +29,23 @@ type sharedPTYGeometry struct { | |||||
| 29 | applied domain.Geometry // mu | 29 | applied domain.Geometry // mu | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | - | // latestValidClaimLocked selects the most recent valid remaining Attachment | 32 | + | // 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 nil | 51 | return nil | ||
| @@ -46,8 +61,7 @@ func (g *sharedPTYGeometry) latestValidClaimLocked(core *sessionCore, exclude *a | |||||
| 46 | continue | 61 | 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 = claim | 65 | latest = claim | ||
| 52 | latestOrder = order | 66 | 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 nil | 96 | 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] = claim | 109 | g.claims[attachment] = claim | ||
| 94 | - | g.latest.Store(claim) | 110 | + | if !g.currentHidesLocked(core, claim) { |
| 111 | + | g.latest.Store(claim) | |||
| 112 | + | } | |||
| 95 | return claim | 113 | 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 republishes | 133 | // 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 | |||
| 51 | 51 | // the focus lets any paint acknowledge. | |
| 52 | 52 | effect.sess.markVisibleAttention(effect.ac) | |
| 53 | 53 | 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") | |
| 57 | 56 | d.invalidateRender(effect.sess, effect.ac, false, "terminal_focus.go") | |
| 58 | 57 | } | |
| 59 | 58 | ||
| @@ -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) { | |||
| 132 | 132 | ||
| 133 | 133 | // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window | |
| 134 | 134 | // 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 | 136 | func TestFocusGainClaimsSharedGeometry(t *testing.T) { | |
| 137 | 137 | tests := []struct { | |
| 138 | 138 | name string | |
| 139 | + | prior domain.TerminalFocus | |
| 139 | 140 | focus domain.TerminalFocus | |
| 140 | 141 | wantClaimed bool | |
| 141 | 142 | }{ | |
| 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}, | |
| 144 | 147 | } | |
| 145 | 148 | for _, tt := range tests { | |
| 146 | 149 | t.Run(tt.name, func(t *testing.T) { | |
| 147 | 150 | d, sess, first, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 148 | 151 | first.setGeometry(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}) | |
| 152 | + | first.setTerminalFocus(tt.prior) | |
| 149 | 153 | second := addMultiplexTestAttachment(t, sess, domain.Geometry{Size: domain.Size{Cols: 120, Rows: 40}}) | |
| 150 | 154 | require.True(t, sess.geometry.reconcile(d, sess, second)) | |
| 151 | 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 | + | } | |
| @@ -132,20 +132,24 @@ func TestFocusReportDrawsTheBellBeforeAcknowledging(t *testing.T) { | |||||
| 132 | 132 | ||||
| 133 | // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window | 133 | // TestFocusGainClaimsSharedGeometry pins that a client whose terminal window | ||
| 134 | // gains focus becomes the shared geometry claimant, while losing focus leaves | 134 | // 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 string | 138 | name string | ||
| 139 | + | prior domain.TerminalFocus | |||
| 139 | focus domain.TerminalFocus | 140 | focus domain.TerminalFocus | ||
| 140 | wantClaimed bool | 141 | 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 | + | } | |||