test(floating): cover responsive resize and input
- commit
- 6f656a7f6534fff62c7d49aea1604b54034c9f35
- author
- brice · · 2026-07-27 16:27 UTC
- committer
- brice · · 2026-07-27 17:06 UTC
- parent
- d1d65ad
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +248 -11
M internal/usecase/daemon/floating_render_test.go +5 -5
| @@ -187,7 +187,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi | |||
| 187 | 187 | require.Equal(t, geometry, gotGeometry) | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | - | func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *testing.T) { | |
| 190 | + | func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) { | |
| 191 | 191 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 192 | 192 | initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |
| 193 | 193 | requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |
| @@ -203,11 +203,11 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *test | |||
| 203 | 203 | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |
| 204 | 204 | require.True(t, ok) | |
| 205 | 205 | require.Empty(t, failed) | |
| 206 | - | require.Equal(t, []domain.Size{{Cols: 1, Rows: 1}}, pty.sizes()) | |
| 206 | + | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | |
| 207 | 207 | require.Equal(t, requested, floating.popupGeometry) | |
| 208 | - | require.Equal(t, domain.Rect{Width: 1, Height: 1}, floating.rect) | |
| 209 | - | require.Equal(t, 1, floating.screen.Frame.Width) | |
| 210 | - | require.Equal(t, 1, floating.screen.Frame.Height) | |
| 208 | + | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") | |
| 209 | + | require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width) | |
| 210 | + | require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height) | |
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |
| @@ -187,7 +187,7 @@ func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testi | |||||
| 187 | require.Equal(t, geometry, gotGeometry) | 187 | require.Equal(t, geometry, gotGeometry) | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | - | func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *testing.T) { | 190 | + | func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) { |
| 191 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | 191 | cfg := domain.FloatingConfig{Width: 100, Height: 100} | ||
| 192 | initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | 192 | initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | ||
| 193 | requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | 193 | requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | ||
| @@ -203,11 +203,11 @@ func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(t *test | |||||
| 203 | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | 203 | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | ||
| 204 | require.True(t, ok) | 204 | require.True(t, ok) | ||
| 205 | require.Empty(t, failed) | 205 | require.Empty(t, failed) | ||
| 206 | - | require.Equal(t, []domain.Size{{Cols: 1, Rows: 1}}, pty.sizes()) | 206 | + | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") |
| 207 | require.Equal(t, requested, floating.popupGeometry) | 207 | require.Equal(t, requested, floating.popupGeometry) | ||
| 208 | - | require.Equal(t, domain.Rect{Width: 1, Height: 1}, floating.rect) | 208 | + | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") |
| 209 | - | require.Equal(t, 1, floating.screen.Frame.Width) | 209 | + | require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width) |
| 210 | - | require.Equal(t, 1, floating.screen.Frame.Height) | 210 | + | require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height) |
| 211 | } | 211 | } | ||
| 212 | 212 | ||||
| 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | ||
M internal/usecase/daemon/floating_test.go +65 -0
| @@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||
| 184 | 184 | d.sessWg.Wait() | |
| 185 | 185 | } | |
| 186 | 186 | ||
| 187 | + | func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |
| 188 | + | initial := domain.Size{Cols: 80, Rows: 25} | |
| 189 | + | latest := domain.Size{Cols: 79, Rows: 25} | |
| 190 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 191 | + | latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg) | |
| 192 | + | ||
| 193 | + | readerRelease := make(chan struct{}) | |
| 194 | + | readerStarted := make(chan struct{}) | |
| 195 | + | resized := make(chan struct{}) | |
| 196 | + | floatingPTY := portsmocks.NewMockPTY(t) | |
| 197 | + | floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |
| 198 | + | close(readerStarted) | |
| 199 | + | <-readerRelease | |
| 200 | + | return 0, io.EOF | |
| 201 | + | }).Once() | |
| 202 | + | floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once() | |
| 203 | + | floatingPTY.EXPECT().Close().RunAndReturn(func() error { | |
| 204 | + | select { | |
| 205 | + | case <-readerRelease: | |
| 206 | + | default: | |
| 207 | + | close(readerRelease) | |
| 208 | + | } | |
| 209 | + | return nil | |
| 210 | + | }).Once() | |
| 211 | + | factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil) | |
| 212 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 213 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 214 | + | tabCtx, cancelTab := context.WithCancel(t.Context()) | |
| 215 | + | t.Cleanup(cancelTab) | |
| 216 | + | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | |
| 217 | + | tb.ctx, tb.cancel = tabCtx, cancelTab | |
| 218 | + | sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |
| 219 | + | ||
| 220 | + | d.startFloating(sess, tb, true) | |
| 221 | + | openCall := <-opened | |
| 222 | + | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | |
| 223 | + | require.True(t, d.requestTransactionalResize(sess, nil, latest, true)) | |
| 224 | + | allowOpen() | |
| 225 | + | select { | |
| 226 | + | case <-resized: | |
| 227 | + | case <-time.After(time.Second): | |
| 228 | + | t.Fatal("floating install did not reconcile the resize committed while Open was blocked") | |
| 229 | + | } | |
| 230 | + | select { | |
| 231 | + | case <-readerStarted: | |
| 232 | + | case <-time.After(time.Second): | |
| 233 | + | t.Fatal("floating reader did not start") | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | require.Eventually(t, func() bool { | |
| 237 | + | tb.mu.Lock() | |
| 238 | + | p := tb.floating.pane | |
| 239 | + | visible := tb.floating.state == floatingVisible | |
| 240 | + | tb.mu.Unlock() | |
| 241 | + | if !visible || p == nil { | |
| 242 | + | return false | |
| 243 | + | } | |
| 244 | + | p.mu.Lock() | |
| 245 | + | defer p.mu.Unlock() | |
| 246 | + | return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner | |
| 247 | + | }, time.Second, time.Millisecond, "floating install retained the stale Open geometry") | |
| 248 | + | d.teardownFloating(tb, nil) | |
| 249 | + | d.sessWg.Wait() | |
| 250 | + | } | |
| 251 | + | ||
| 187 | 252 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | |
| 188 | 253 | floatingPTY := portsmocks.NewMockPTY(t) | |
| 189 | 254 | readerStarted := make(chan struct{}) | |
| @@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||||
| 184 | d.sessWg.Wait() | 184 | d.sessWg.Wait() | ||
| 185 | } | 185 | } | ||
| 186 | 186 | ||||
| 187 | + | func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |||
| 188 | + | initial := domain.Size{Cols: 80, Rows: 25} | |||
| 189 | + | latest := domain.Size{Cols: 79, Rows: 25} | |||
| 190 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 191 | + | latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg) | |||
| 192 | + | ||||
| 193 | + | readerRelease := make(chan struct{}) | |||
| 194 | + | readerStarted := make(chan struct{}) | |||
| 195 | + | resized := make(chan struct{}) | |||
| 196 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 197 | + | floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |||
| 198 | + | close(readerStarted) | |||
| 199 | + | <-readerRelease | |||
| 200 | + | return 0, io.EOF | |||
| 201 | + | }).Once() | |||
| 202 | + | floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once() | |||
| 203 | + | floatingPTY.EXPECT().Close().RunAndReturn(func() error { | |||
| 204 | + | select { | |||
| 205 | + | case <-readerRelease: | |||
| 206 | + | default: | |||
| 207 | + | close(readerRelease) | |||
| 208 | + | } | |||
| 209 | + | return nil | |||
| 210 | + | }).Once() | |||
| 211 | + | factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil) | |||
| 212 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 213 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 214 | + | tabCtx, cancelTab := context.WithCancel(t.Context()) | |||
| 215 | + | t.Cleanup(cancelTab) | |||
| 216 | + | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | |||
| 217 | + | tb.ctx, tb.cancel = tabCtx, cancelTab | |||
| 218 | + | sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 219 | + | ||||
| 220 | + | d.startFloating(sess, tb, true) | |||
| 221 | + | openCall := <-opened | |||
| 222 | + | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | |||
| 223 | + | require.True(t, d.requestTransactionalResize(sess, nil, latest, true)) | |||
| 224 | + | allowOpen() | |||
| 225 | + | select { | |||
| 226 | + | case <-resized: | |||
| 227 | + | case <-time.After(time.Second): | |||
| 228 | + | t.Fatal("floating install did not reconcile the resize committed while Open was blocked") | |||
| 229 | + | } | |||
| 230 | + | select { | |||
| 231 | + | case <-readerStarted: | |||
| 232 | + | case <-time.After(time.Second): | |||
| 233 | + | t.Fatal("floating reader did not start") | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | require.Eventually(t, func() bool { | |||
| 237 | + | tb.mu.Lock() | |||
| 238 | + | p := tb.floating.pane | |||
| 239 | + | visible := tb.floating.state == floatingVisible | |||
| 240 | + | tb.mu.Unlock() | |||
| 241 | + | if !visible || p == nil { | |||
| 242 | + | return false | |||
| 243 | + | } | |||
| 244 | + | p.mu.Lock() | |||
| 245 | + | defer p.mu.Unlock() | |||
| 246 | + | return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner | |||
| 247 | + | }, time.Second, time.Millisecond, "floating install retained the stale Open geometry") | |||
| 248 | + | d.teardownFloating(tb, nil) | |||
| 249 | + | d.sessWg.Wait() | |||
| 250 | + | } | |||
| 251 | + | ||||
| 187 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | 252 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | ||
| 188 | floatingPTY := portsmocks.NewMockPTY(t) | 253 | floatingPTY := portsmocks.NewMockPTY(t) | ||
| 189 | readerStarted := make(chan struct{}) | 254 | readerStarted := make(chan struct{}) | ||
M internal/usecase/daemon/input.go +2 -2
| @@ -60,7 +60,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 60 | 60 | } | |
| 61 | 61 | ||
| 62 | 62 | tb.mu.Lock() | |
| 63 | - | contentRow := ev.Row - 1 | |
| 63 | + | contentRow := ev.Row - clientTopBarRows | |
| 64 | 64 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 65 | 65 | if floatingVisible { | |
| 66 | 66 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |
| @@ -169,7 +169,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 169 | 169 | if translated { | |
| 170 | 170 | d.writeToPane(sess, p, ev.Raw) | |
| 171 | 171 | } else { | |
| 172 | - | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1)) | |
| 172 | + | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) | |
| 173 | 173 | } | |
| 174 | 174 | return | |
| 175 | 175 | } | |
| @@ -60,7 +60,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 60 | } | 60 | } | ||
| 61 | 61 | ||||
| 62 | tb.mu.Lock() | 62 | tb.mu.Lock() | ||
| 63 | - | contentRow := ev.Row - 1 | 63 | + | contentRow := ev.Row - clientTopBarRows |
| 64 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | 64 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | ||
| 65 | if floatingVisible { | 65 | if floatingVisible { | ||
| 66 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | 66 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | ||
| @@ -169,7 +169,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 169 | if translated { | 169 | if translated { | ||
| 170 | d.writeToPane(sess, p, ev.Raw) | 170 | d.writeToPane(sess, p, ev.Raw) | ||
| 171 | } else { | 171 | } else { | ||
| 172 | - | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1)) | 172 | + | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) |
| 173 | } | 173 | } | ||
| 174 | return | 174 | return | ||
| 175 | } | 175 | } | ||
M internal/usecase/daemon/input_test.go +75 -2
| @@ -21,6 +21,7 @@ import ( | |||
| 21 | 21 | "github.com/bnema/vev/internal/usecase/layout" | |
| 22 | 22 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 23 | 23 | "github.com/bnema/vev/internal/usecase/picker" | |
| 24 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 24 | 25 | "github.com/bnema/vev/pkg/vt" | |
| 25 | 26 | ) | |
| 26 | 27 | ||
| @@ -602,7 +603,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||
| 602 | 603 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 603 | 604 | installTestFloating(tb, floating, true) | |
| 604 | 605 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | |
| 605 | - | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | |
| 606 | + | raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |
| 606 | 607 | ||
| 607 | 608 | d.handleInput(sess, ac, raw) | |
| 608 | 609 | } | |
| @@ -620,12 +621,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||
| 620 | 621 | installTestFloating(tb, floating, true) | |
| 621 | 622 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | |
| 622 | 623 | ||
| 623 | - | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | |
| 624 | + | border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1) | |
| 624 | 625 | outside := []byte("\x1b[<0;1;2M") | |
| 625 | 626 | d.handleInput(sess, ac, border) | |
| 626 | 627 | d.handleInput(sess, ac, outside) | |
| 627 | 628 | } | |
| 628 | 629 | ||
| 630 | + | func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) { | |
| 631 | + | normal := portsmocks.NewMockPTY(t) | |
| 632 | + | floatingPTY := portsmocks.NewMockPTY(t) | |
| 633 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |
| 634 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |
| 635 | + | tb := sess.activeTab() | |
| 636 | + | complete := domain.Size{Cols: 79, Rows: 25} | |
| 637 | + | content := tabSize(complete) | |
| 638 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |
| 639 | + | require.Equal(t, ui.PresentationDrawer, geometry.Mode) | |
| 640 | + | tb.mu.Lock() | |
| 641 | + | tb.size = content | |
| 642 | + | tb.mu.Unlock() | |
| 643 | + | floating := newPane("floating", floatingPTY, rectSize(geometry.Inner)) | |
| 644 | + | floating.popupGeometry = geometry | |
| 645 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 646 | + | installTestFloating(tb, floating, true) | |
| 647 | + | ||
| 648 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |
| 649 | + | d.handleInput(sess, ac, firstInner) | |
| 650 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |
| 651 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |
| 652 | + | } | |
| 653 | + | } | |
| 654 | + | ||
| 655 | + | func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) { | |
| 656 | + | p, release := newBlockingPTY(t) | |
| 657 | + | defer release() | |
| 658 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 659 | + | tb := sess.activeTab() | |
| 660 | + | complete := domain.Size{Cols: 79, Rows: 25} | |
| 661 | + | content := tabSize(complete) | |
| 662 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |
| 663 | + | tb.mu.Lock() | |
| 664 | + | tb.size = content | |
| 665 | + | tb.mu.Unlock() | |
| 666 | + | floating := newPane("floating", nil, rectSize(geometry.Inner)) | |
| 667 | + | floating.popupGeometry = geometry | |
| 668 | + | floating.screen.Write([]byte("drawer row zero\r\nsecond row")) | |
| 669 | + | installTestFloating(tb, floating, true) | |
| 670 | + | ||
| 671 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |
| 672 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |
| 673 | + | ac.overlays.copyMu.Lock() | |
| 674 | + | require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow) | |
| 675 | + | ac.overlays.copyMu.Unlock() | |
| 676 | + | } | |
| 677 | + | ||
| 678 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |
| 679 | + | d.handleInput(sess, ac, firstInner) | |
| 680 | + | ac.overlays.copyMu.Lock() | |
| 681 | + | pointer := ac.overlays.copyPointer | |
| 682 | + | ac.overlays.copyMu.Unlock() | |
| 683 | + | require.True(t, pointer.valid) | |
| 684 | + | require.Same(t, floating, pointer.pane) | |
| 685 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press) | |
| 686 | + | require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content) | |
| 687 | + | ||
| 688 | + | // Motion onto the protected top row clamps against the press-owned drawer | |
| 689 | + | // geometry instead of retargeting the underlying tiled terminal. | |
| 690 | + | d.handleInput(sess, ac, []byte("\x1b[<32;79;1M")) | |
| 691 | + | ac.overlays.copyMu.Lock() | |
| 692 | + | require.NotNil(t, ac.overlays.copyMode) | |
| 693 | + | require.Same(t, floating, ac.overlays.copyPane) | |
| 694 | + | require.Same(t, floating, ac.overlays.copyPointer.pane) | |
| 695 | + | require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry) | |
| 696 | + | selection := ac.overlays.copyMode.Selection() | |
| 697 | + | ac.overlays.copyMu.Unlock() | |
| 698 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |
| 699 | + | require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row") | |
| 700 | + | } | |
| 701 | + | ||
| 629 | 702 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |
| 630 | 703 | writes := make(chan []byte, 1) | |
| 631 | 704 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | |
| @@ -21,6 +21,7 @@ import ( | |||||
| 21 | "github.com/bnema/vev/internal/usecase/layout" | 21 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 22 | "github.com/bnema/vev/internal/usecase/mouse" | 22 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 23 | "github.com/bnema/vev/internal/usecase/picker" | 23 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 24 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 24 | "github.com/bnema/vev/pkg/vt" | 25 | "github.com/bnema/vev/pkg/vt" | ||
| 25 | ) | 26 | ) | ||
| 26 | 27 | ||||
| @@ -602,7 +603,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||||
| 602 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | 603 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | ||
| 603 | installTestFloating(tb, floating, true) | 604 | installTestFloating(tb, floating, true) | ||
| 604 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | 605 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | ||
| 605 | - | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | 606 | + | raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) |
| 606 | 607 | ||||
| 607 | d.handleInput(sess, ac, raw) | 608 | d.handleInput(sess, ac, raw) | ||
| 608 | } | 609 | } | ||
| @@ -620,12 +621,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||||
| 620 | installTestFloating(tb, floating, true) | 621 | installTestFloating(tb, floating, true) | ||
| 621 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | 622 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | ||
| 622 | 623 | ||||
| 623 | - | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | 624 | + | border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1) |
| 624 | outside := []byte("\x1b[<0;1;2M") | 625 | outside := []byte("\x1b[<0;1;2M") | ||
| 625 | d.handleInput(sess, ac, border) | 626 | d.handleInput(sess, ac, border) | ||
| 626 | d.handleInput(sess, ac, outside) | 627 | d.handleInput(sess, ac, outside) | ||
| 627 | } | 628 | } | ||
| 628 | 629 | ||||
| 630 | + | func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) { | |||
| 631 | + | normal := portsmocks.NewMockPTY(t) | |||
| 632 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 633 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |||
| 634 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 635 | + | tb := sess.activeTab() | |||
| 636 | + | complete := domain.Size{Cols: 79, Rows: 25} | |||
| 637 | + | content := tabSize(complete) | |||
| 638 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |||
| 639 | + | require.Equal(t, ui.PresentationDrawer, geometry.Mode) | |||
| 640 | + | tb.mu.Lock() | |||
| 641 | + | tb.size = content | |||
| 642 | + | tb.mu.Unlock() | |||
| 643 | + | floating := newPane("floating", floatingPTY, rectSize(geometry.Inner)) | |||
| 644 | + | floating.popupGeometry = geometry | |||
| 645 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 646 | + | installTestFloating(tb, floating, true) | |||
| 647 | + | ||||
| 648 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |||
| 649 | + | d.handleInput(sess, ac, firstInner) | |||
| 650 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |||
| 651 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |||
| 652 | + | } | |||
| 653 | + | } | |||
| 654 | + | ||||
| 655 | + | func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) { | |||
| 656 | + | p, release := newBlockingPTY(t) | |||
| 657 | + | defer release() | |||
| 658 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 659 | + | tb := sess.activeTab() | |||
| 660 | + | complete := domain.Size{Cols: 79, Rows: 25} | |||
| 661 | + | content := tabSize(complete) | |||
| 662 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |||
| 663 | + | tb.mu.Lock() | |||
| 664 | + | tb.size = content | |||
| 665 | + | tb.mu.Unlock() | |||
| 666 | + | floating := newPane("floating", nil, rectSize(geometry.Inner)) | |||
| 667 | + | floating.popupGeometry = geometry | |||
| 668 | + | floating.screen.Write([]byte("drawer row zero\r\nsecond row")) | |||
| 669 | + | installTestFloating(tb, floating, true) | |||
| 670 | + | ||||
| 671 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |||
| 672 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |||
| 673 | + | ac.overlays.copyMu.Lock() | |||
| 674 | + | require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow) | |||
| 675 | + | ac.overlays.copyMu.Unlock() | |||
| 676 | + | } | |||
| 677 | + | ||||
| 678 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |||
| 679 | + | d.handleInput(sess, ac, firstInner) | |||
| 680 | + | ac.overlays.copyMu.Lock() | |||
| 681 | + | pointer := ac.overlays.copyPointer | |||
| 682 | + | ac.overlays.copyMu.Unlock() | |||
| 683 | + | require.True(t, pointer.valid) | |||
| 684 | + | require.Same(t, floating, pointer.pane) | |||
| 685 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press) | |||
| 686 | + | require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content) | |||
| 687 | + | ||||
| 688 | + | // Motion onto the protected top row clamps against the press-owned drawer | |||
| 689 | + | // geometry instead of retargeting the underlying tiled terminal. | |||
| 690 | + | d.handleInput(sess, ac, []byte("\x1b[<32;79;1M")) | |||
| 691 | + | ac.overlays.copyMu.Lock() | |||
| 692 | + | require.NotNil(t, ac.overlays.copyMode) | |||
| 693 | + | require.Same(t, floating, ac.overlays.copyPane) | |||
| 694 | + | require.Same(t, floating, ac.overlays.copyPointer.pane) | |||
| 695 | + | require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry) | |||
| 696 | + | selection := ac.overlays.copyMode.Selection() | |||
| 697 | + | ac.overlays.copyMu.Unlock() | |||
| 698 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |||
| 699 | + | require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row") | |||
| 700 | + | } | |||
| 701 | + | ||||
| 629 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | 702 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | ||
| 630 | writes := make(chan []byte, 1) | 703 | writes := make(chan []byte, 1) | ||
| 631 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 704 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
M internal/usecase/daemon/mouse_panes.go +1 -1
| @@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |||
| 187 | 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | |
| 188 | 188 | ev.Col -= colOffset | |
| 189 | 189 | ev.Row -= contentYOffset | |
| 190 | - | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | |
| 190 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows) | |
| 191 | 191 | return ev | |
| 192 | 192 | } | |
| 193 | 193 | ||
| @@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |||||
| 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | ||
| 188 | ev.Col -= colOffset | 188 | ev.Col -= colOffset | ||
| 189 | ev.Row -= contentYOffset | 189 | ev.Row -= contentYOffset | ||
| 190 | - | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | 190 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows) |
| 191 | return ev | 191 | return ev | ||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
M internal/usecase/daemon/transactional_resize.go +21 -0
| @@ -369,6 +369,27 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 369 | 369 | if !geometry.committable() { | |
| 370 | 370 | return nil, true | |
| 371 | 371 | } | |
| 372 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |
| 373 | + | // A drawer can validly reserve only its separator row. Publish that | |
| 374 | + | // presentation without inventing a physical PTY size; the last usable | |
| 375 | + | // rectangle and screen remain committed until a later resize has content. | |
| 376 | + | tb.mu.Lock() | |
| 377 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && | |
| 378 | + | tb.floating.pane == p && tb.size == size | |
| 379 | + | if current != nil && !current() { | |
| 380 | + | currentSlot = false | |
| 381 | + | } | |
| 382 | + | if currentSlot { | |
| 383 | + | p.mu.Lock() | |
| 384 | + | p.popupGeometry = geometry | |
| 385 | + | p.mu.Unlock() | |
| 386 | + | } | |
| 387 | + | tb.mu.Unlock() | |
| 388 | + | if current != nil && !current() { | |
| 389 | + | return nil, false | |
| 390 | + | } | |
| 391 | + | return nil, true | |
| 392 | + | } | |
| 372 | 393 | ||
| 373 | 394 | // This keeps successful PTY resizes gated until the | |
| 374 | 395 | // floating slot and tab size are revalidated. A newer client resize may | |
| @@ -369,6 +369,27 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 369 | if !geometry.committable() { | 369 | if !geometry.committable() { | ||
| 370 | return nil, true | 370 | return nil, true | ||
| 371 | } | 371 | } | ||
| 372 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |||
| 373 | + | // A drawer can validly reserve only its separator row. Publish that | |||
| 374 | + | // presentation without inventing a physical PTY size; the last usable | |||
| 375 | + | // rectangle and screen remain committed until a later resize has content. | |||
| 376 | + | tb.mu.Lock() | |||
| 377 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && | |||
| 378 | + | tb.floating.pane == p && tb.size == size | |||
| 379 | + | if current != nil && !current() { | |||
| 380 | + | currentSlot = false | |||
| 381 | + | } | |||
| 382 | + | if currentSlot { | |||
| 383 | + | p.mu.Lock() | |||
| 384 | + | p.popupGeometry = geometry | |||
| 385 | + | p.mu.Unlock() | |||
| 386 | + | } | |||
| 387 | + | tb.mu.Unlock() | |||
| 388 | + | if current != nil && !current() { | |||
| 389 | + | return nil, false | |||
| 390 | + | } | |||
| 391 | + | return nil, true | |||
| 392 | + | } | |||
| 372 | 393 | ||||
| 373 | // This keeps successful PTY resizes gated until the | 394 | // This keeps successful PTY resizes gated until the | ||
| 374 | // floating slot and tab size are revalidated. A newer client resize may | 395 | // floating slot and tab size are revalidated. A newer client resize may | ||
M internal/usecase/daemon/transactional_resize_test.go +79 -1
| @@ -484,11 +484,65 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||
| 484 | 484 | tb.mu.Unlock() | |
| 485 | 485 | } | |
| 486 | 486 | ||
| 487 | + | func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |
| 488 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 489 | + | for _, tc := range []struct { | |
| 490 | + | name string | |
| 491 | + | from domain.Size | |
| 492 | + | to domain.Size | |
| 493 | + | wantMode ui.PresentationMode | |
| 494 | + | }{ | |
| 495 | + | {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer}, | |
| 496 | + | {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating}, | |
| 497 | + | } { | |
| 498 | + | t.Run(tc.name, func(t *testing.T) { | |
| 499 | + | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| 500 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 501 | + | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | |
| 502 | + | ||
| 503 | + | tb := sess.activeTab() | |
| 504 | + | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | |
| 505 | + | toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg) | |
| 506 | + | entered, release := make(chan struct{}), make(chan struct{}) | |
| 507 | + | var once sync.Once | |
| 508 | + | popupPTY := &transactionalResizePTY{onResize: func() { | |
| 509 | + | once.Do(func() { close(entered) }) | |
| 510 | + | <-release | |
| 511 | + | }} | |
| 512 | + | popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner)) | |
| 513 | + | popup.rect = fromGeometry.Inner | |
| 514 | + | popup.popupGeometry = fromGeometry | |
| 515 | + | installTestFloating(tb, popup, true) | |
| 516 | + | ||
| 517 | + | done := make(chan bool, 1) | |
| 518 | + | go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }() | |
| 519 | + | <-entered | |
| 520 | + | popup.mu.Lock() | |
| 521 | + | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | |
| 522 | + | require.Equal(t, fromGeometry.Inner, popup.rect) | |
| 523 | + | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 524 | + | popup.mu.Unlock() | |
| 525 | + | close(release) | |
| 526 | + | require.True(t, <-done) | |
| 527 | + | ||
| 528 | + | require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested()) | |
| 529 | + | popup.mu.Lock() | |
| 530 | + | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | |
| 531 | + | require.Equal(t, toGeometry, popup.popupGeometry) | |
| 532 | + | require.Equal(t, toGeometry.Inner, popup.rect) | |
| 533 | + | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 534 | + | popup.mu.Unlock() | |
| 535 | + | }) | |
| 536 | + | } | |
| 537 | + | } | |
| 538 | + | ||
| 487 | 539 | func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) { | |
| 488 | 540 | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |
| 489 | 541 | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg) | |
| 490 | - | requestedSize := domain.Size{Cols: 100, Rows: 40} | |
| 542 | + | requestedSize := domain.Size{Cols: 79, Rows: 40} | |
| 491 | 543 | requested := calculateContentFloatingGeometry(requestedSize, cfg) | |
| 544 | + | require.Equal(t, ui.PresentationFloating, committed.Mode) | |
| 545 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |
| 492 | 546 | resizeErr := errors.New("floating resize failed") | |
| 493 | 547 | pty := &transactionalResizePTY{errs: []error{resizeErr}} | |
| 494 | 548 | popup := newPane("floating", pty, rectSize(committed.Inner)) | |
| @@ -563,6 +617,30 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t * | |||
| 563 | 617 | popup.mu.Unlock() | |
| 564 | 618 | } | |
| 565 | 619 | ||
| 620 | + | func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) { | |
| 621 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 622 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |
| 623 | + | pty := &transactionalResizePTY{} | |
| 624 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |
| 625 | + | popup.rect = committed.Inner | |
| 626 | + | popup.popupGeometry = committed | |
| 627 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |
| 628 | + | installTestFloating(tb, popup, true) | |
| 629 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 630 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 631 | + | ||
| 632 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false }) | |
| 633 | + | ||
| 634 | + | require.False(t, ok) | |
| 635 | + | require.Empty(t, failed) | |
| 636 | + | require.Empty(t, pty.requested()) | |
| 637 | + | popup.mu.Lock() | |
| 638 | + | require.Equal(t, committed, popup.popupGeometry) | |
| 639 | + | require.Equal(t, committed.Inner, popup.rect) | |
| 640 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |
| 641 | + | popup.mu.Unlock() | |
| 642 | + | } | |
| 643 | + | ||
| 566 | 644 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | |
| 567 | 645 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | |
| 568 | 646 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |
| @@ -484,11 +484,65 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) { | |||||
| 484 | tb.mu.Unlock() | 484 | tb.mu.Unlock() | ||
| 485 | } | 485 | } | ||
| 486 | 486 | ||||
| 487 | + | func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 488 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 489 | + | for _, tc := range []struct { | |||
| 490 | + | name string | |||
| 491 | + | from domain.Size | |||
| 492 | + | to domain.Size | |||
| 493 | + | wantMode ui.PresentationMode | |||
| 494 | + | }{ | |||
| 495 | + | {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer}, | |||
| 496 | + | {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating}, | |||
| 497 | + | } { | |||
| 498 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 499 | + | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 500 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 501 | + | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | |||
| 502 | + | ||||
| 503 | + | tb := sess.activeTab() | |||
| 504 | + | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | |||
| 505 | + | toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg) | |||
| 506 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 507 | + | var once sync.Once | |||
| 508 | + | popupPTY := &transactionalResizePTY{onResize: func() { | |||
| 509 | + | once.Do(func() { close(entered) }) | |||
| 510 | + | <-release | |||
| 511 | + | }} | |||
| 512 | + | popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner)) | |||
| 513 | + | popup.rect = fromGeometry.Inner | |||
| 514 | + | popup.popupGeometry = fromGeometry | |||
| 515 | + | installTestFloating(tb, popup, true) | |||
| 516 | + | ||||
| 517 | + | done := make(chan bool, 1) | |||
| 518 | + | go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }() | |||
| 519 | + | <-entered | |||
| 520 | + | popup.mu.Lock() | |||
| 521 | + | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | |||
| 522 | + | require.Equal(t, fromGeometry.Inner, popup.rect) | |||
| 523 | + | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 524 | + | popup.mu.Unlock() | |||
| 525 | + | close(release) | |||
| 526 | + | require.True(t, <-done) | |||
| 527 | + | ||||
| 528 | + | require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested()) | |||
| 529 | + | popup.mu.Lock() | |||
| 530 | + | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | |||
| 531 | + | require.Equal(t, toGeometry, popup.popupGeometry) | |||
| 532 | + | require.Equal(t, toGeometry.Inner, popup.rect) | |||
| 533 | + | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 534 | + | popup.mu.Unlock() | |||
| 535 | + | }) | |||
| 536 | + | } | |||
| 537 | + | } | |||
| 538 | + | ||||
| 487 | func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) { | 539 | func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) { | ||
| 488 | cfg := domain.FloatingConfig{Width: 50, Height: 50} | 540 | cfg := domain.FloatingConfig{Width: 50, Height: 50} | ||
| 489 | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg) | 541 | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg) | ||
| 490 | - | requestedSize := domain.Size{Cols: 100, Rows: 40} | 542 | + | requestedSize := domain.Size{Cols: 79, Rows: 40} |
| 491 | requested := calculateContentFloatingGeometry(requestedSize, cfg) | 543 | requested := calculateContentFloatingGeometry(requestedSize, cfg) | ||
| 544 | + | require.Equal(t, ui.PresentationFloating, committed.Mode) | |||
| 545 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |||
| 492 | resizeErr := errors.New("floating resize failed") | 546 | resizeErr := errors.New("floating resize failed") | ||
| 493 | pty := &transactionalResizePTY{errs: []error{resizeErr}} | 547 | pty := &transactionalResizePTY{errs: []error{resizeErr}} | ||
| 494 | popup := newPane("floating", pty, rectSize(committed.Inner)) | 548 | popup := newPane("floating", pty, rectSize(committed.Inner)) | ||
| @@ -563,6 +617,30 @@ func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t * | |||||
| 563 | popup.mu.Unlock() | 617 | popup.mu.Unlock() | ||
| 564 | } | 618 | } | ||
| 565 | 619 | ||||
| 620 | + | func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) { | |||
| 621 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 622 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |||
| 623 | + | pty := &transactionalResizePTY{} | |||
| 624 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 625 | + | popup.rect = committed.Inner | |||
| 626 | + | popup.popupGeometry = committed | |||
| 627 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 628 | + | installTestFloating(tb, popup, true) | |||
| 629 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 630 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 631 | + | ||||
| 632 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false }) | |||
| 633 | + | ||||
| 634 | + | require.False(t, ok) | |||
| 635 | + | require.Empty(t, failed) | |||
| 636 | + | require.Empty(t, pty.requested()) | |||
| 637 | + | popup.mu.Lock() | |||
| 638 | + | require.Equal(t, committed, popup.popupGeometry) | |||
| 639 | + | require.Equal(t, committed.Inner, popup.rect) | |||
| 640 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 641 | + | popup.mu.Unlock() | |||
| 642 | + | } | |||
| 643 | + | ||||
| 566 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | 644 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | ||
| 567 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | 645 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | ||
| 568 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 646 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||