Merge pull request #130 from bnema/fix/stacked-pane-hide-active-title
fix(layout): hide the active stacked pane's title bar
9 files · +110 -35
M internal/usecase/daemon/control.go +1 -1
| @@ -356,7 +356,7 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att | |||
| 356 | 356 | tb.mu.Unlock() | |
| 357 | 357 | if newFocus != request.target.pane.id { | |
| 358 | 358 | d.exitCopyMode(ac) | |
| 359 | - | if hasPlacement && pl.TitleBar.Height > 0 { | |
| 359 | + | if hasPlacement && pl.InStack { | |
| 360 | 360 | d.refreshPaneTitleOnFocus(request.target.session, newFocus) | |
| 361 | 361 | } | |
| 362 | 362 | } | |
| @@ -356,7 +356,7 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att | |||||
| 356 | tb.mu.Unlock() | 356 | tb.mu.Unlock() | ||
| 357 | if newFocus != request.target.pane.id { | 357 | if newFocus != request.target.pane.id { | ||
| 358 | d.exitCopyMode(ac) | 358 | d.exitCopyMode(ac) | ||
| 359 | - | if hasPlacement && pl.TitleBar.Height > 0 { | 359 | + | if hasPlacement && pl.InStack { |
| 360 | d.refreshPaneTitleOnFocus(request.target.session, newFocus) | 360 | d.refreshPaneTitleOnFocus(request.target.session, newFocus) | ||
| 361 | } | 361 | } | ||
| 362 | } | 362 | } | ||
M internal/usecase/daemon/input_test.go +18 -10
| @@ -1387,12 +1387,12 @@ func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) | |||
| 1387 | 1387 | ac.overlays.copyMu.Unlock() | |
| 1388 | 1388 | } | |
| 1389 | 1389 | ||
| 1390 | - | func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing.T) { | |
| 1390 | + | func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) { | |
| 1391 | 1391 | p, release := newBlockingPTY(t) | |
| 1392 | 1392 | defer release() | |
| 1393 | 1393 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1394 | 1394 | tb := sess.activeTab() | |
| 1395 | - | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 8}) | |
| 1395 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9}) | |
| 1396 | 1396 | p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1}) | |
| 1397 | 1397 | tb.mu.Lock() | |
| 1398 | 1398 | tb.size = domain.Size{Cols: 41, Rows: 10} | |
| @@ -1405,25 +1405,33 @@ func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing | |||
| 1405 | 1405 | tb.panes["pane-3"] = p3 | |
| 1406 | 1406 | tb.mu.Unlock() | |
| 1407 | 1407 | ||
| 1408 | - | // A content press gives pane-2 a fresh candidate. Pressing that already | |
| 1409 | - | // focused pane's title bar must make the following motion a no-op rather | |
| 1410 | - | // than reusing this pointer to publish copy mode. | |
| 1408 | + | // pane-2 is the stack's expanded member, so it draws no title bar and its | |
| 1409 | + | // content now starts at the row that used to hold that title bar (row 0 of | |
| 1410 | + | // the stack, screen row 1, i.e. cy=2 in 1-based SGR coordinates). A content | |
| 1411 | + | // press gives pane-2 a fresh candidate. | |
| 1411 | 1412 | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | |
| 1412 | 1413 | ac.overlays.copyMu.Lock() | |
| 1413 | 1414 | require.True(t, ac.overlays.copyPointer.valid) | |
| 1414 | 1415 | epoch := ac.overlays.copyPointerEpoch | |
| 1415 | 1416 | ac.overlays.copyMu.Unlock() | |
| 1416 | 1417 | ||
| 1418 | + | // Pressing the row that used to be the title bar must be routed as an | |
| 1419 | + | // ordinary content press on the already-focused pane-2, not a title-bar | |
| 1420 | + | // hit: it replaces the candidate with a fresh one rather than leaving a | |
| 1421 | + | // stale/invalidated pointer, and focus is unchanged since pane-2 was | |
| 1422 | + | // already focused. | |
| 1417 | 1423 | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | |
| 1418 | 1424 | ac.overlays.copyMu.Lock() | |
| 1419 | - | require.False(t, ac.overlays.copyPointer.valid) | |
| 1425 | + | require.True(t, ac.overlays.copyPointer.valid) | |
| 1420 | 1426 | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | |
| 1421 | 1427 | ac.overlays.copyMu.Unlock() | |
| 1422 | - | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | |
| 1428 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "press on already-focused pane's content must preserve focus") | |
| 1423 | 1429 | ||
| 1430 | + | // A drag from that press is a legitimate content selection now (there is | |
| 1431 | + | // no title bar to have blocked it), so it publishes copy mode. | |
| 1424 | 1432 | d.handleInput(sess, ac, []byte("\x1b[<32;22;4M")) | |
| 1425 | 1433 | ac.overlays.copyMu.Lock() | |
| 1426 | - | require.Nil(t, ac.overlays.copyMode, "motion after title-bar press must not publish stale pointer state") | |
| 1434 | + | require.NotNil(t, ac.overlays.copyMode, "drag from a content press must publish copy mode") | |
| 1427 | 1435 | ac.overlays.copyMu.Unlock() | |
| 1428 | 1436 | } | |
| 1429 | 1437 | ||
| @@ -1564,9 +1572,9 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) { | |||
| 1564 | 1572 | ||
| 1565 | 1573 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | |
| 1566 | 1574 | p1 := portsmocks.NewMockPTY(t) | |
| 1567 | - | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | |
| 1575 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe() | |
| 1568 | 1576 | p2 := portsmocks.NewMockPTY(t) | |
| 1569 | - | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | |
| 1577 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe() | |
| 1570 | 1578 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | |
| 1571 | 1579 | d.procComm = nil | |
| 1572 | 1580 | tb := sess.activeTab() | |
| @@ -1387,12 +1387,12 @@ func TestRejectedLeftPressAndInactiveReleaseInvalidateStalePointer(t *testing.T) | |||||
| 1387 | ac.overlays.copyMu.Unlock() | 1387 | ac.overlays.copyMu.Unlock() | ||
| 1388 | } | 1388 | } | ||
| 1389 | 1389 | ||||
| 1390 | - | func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing.T) { | 1390 | + | func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) { |
| 1391 | p, release := newBlockingPTY(t) | 1391 | p, release := newBlockingPTY(t) | ||
| 1392 | defer release() | 1392 | defer release() | ||
| 1393 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 1393 | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | ||
| 1394 | tb := sess.activeTab() | 1394 | tb := sess.activeTab() | ||
| 1395 | - | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 8}) | 1395 | + | p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 9}) |
| 1396 | p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1}) | 1396 | p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1}) | ||
| 1397 | tb.mu.Lock() | 1397 | tb.mu.Lock() | ||
| 1398 | tb.size = domain.Size{Cols: 41, Rows: 10} | 1398 | tb.size = domain.Size{Cols: 41, Rows: 10} | ||
| @@ -1405,25 +1405,33 @@ func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing | |||||
| 1405 | tb.panes["pane-3"] = p3 | 1405 | tb.panes["pane-3"] = p3 | ||
| 1406 | tb.mu.Unlock() | 1406 | tb.mu.Unlock() | ||
| 1407 | 1407 | ||||
| 1408 | - | // A content press gives pane-2 a fresh candidate. Pressing that already | 1408 | + | // pane-2 is the stack's expanded member, so it draws no title bar and its |
| 1409 | - | // focused pane's title bar must make the following motion a no-op rather | 1409 | + | // content now starts at the row that used to hold that title bar (row 0 of |
| 1410 | - | // than reusing this pointer to publish copy mode. | 1410 | + | // the stack, screen row 1, i.e. cy=2 in 1-based SGR coordinates). A content |
| 1411 | + | // press gives pane-2 a fresh candidate. | |||
| 1411 | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | 1412 | d.handleInput(sess, ac, []byte("\x1b[<0;22;3M")) | ||
| 1412 | ac.overlays.copyMu.Lock() | 1413 | ac.overlays.copyMu.Lock() | ||
| 1413 | require.True(t, ac.overlays.copyPointer.valid) | 1414 | require.True(t, ac.overlays.copyPointer.valid) | ||
| 1414 | epoch := ac.overlays.copyPointerEpoch | 1415 | epoch := ac.overlays.copyPointerEpoch | ||
| 1415 | ac.overlays.copyMu.Unlock() | 1416 | ac.overlays.copyMu.Unlock() | ||
| 1416 | 1417 | ||||
| 1418 | + | // Pressing the row that used to be the title bar must be routed as an | |||
| 1419 | + | // ordinary content press on the already-focused pane-2, not a title-bar | |||
| 1420 | + | // hit: it replaces the candidate with a fresh one rather than leaving a | |||
| 1421 | + | // stale/invalidated pointer, and focus is unchanged since pane-2 was | |||
| 1422 | + | // already focused. | |||
| 1417 | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | 1423 | d.handleInput(sess, ac, []byte("\x1b[<0;22;2M")) | ||
| 1418 | ac.overlays.copyMu.Lock() | 1424 | ac.overlays.copyMu.Lock() | ||
| 1419 | - | require.False(t, ac.overlays.copyPointer.valid) | 1425 | + | require.True(t, ac.overlays.copyPointer.valid) |
| 1420 | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | 1426 | require.Greater(t, ac.overlays.copyPointerEpoch, epoch) | ||
| 1421 | ac.overlays.copyMu.Unlock() | 1427 | ac.overlays.copyMu.Unlock() | ||
| 1422 | - | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "title press on focused pane must preserve focus") | 1428 | + | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus, "press on already-focused pane's content must preserve focus") |
| 1423 | 1429 | ||||
| 1430 | + | // A drag from that press is a legitimate content selection now (there is | |||
| 1431 | + | // no title bar to have blocked it), so it publishes copy mode. | |||
| 1424 | d.handleInput(sess, ac, []byte("\x1b[<32;22;4M")) | 1432 | d.handleInput(sess, ac, []byte("\x1b[<32;22;4M")) | ||
| 1425 | ac.overlays.copyMu.Lock() | 1433 | ac.overlays.copyMu.Lock() | ||
| 1426 | - | require.Nil(t, ac.overlays.copyMode, "motion after title-bar press must not publish stale pointer state") | 1434 | + | require.NotNil(t, ac.overlays.copyMode, "drag from a content press must publish copy mode") |
| 1427 | ac.overlays.copyMu.Unlock() | 1435 | ac.overlays.copyMu.Unlock() | ||
| 1428 | } | 1436 | } | ||
| 1429 | 1437 | ||||
| @@ -1564,9 +1572,9 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) { | |||||
| 1564 | 1572 | ||||
| 1565 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | 1573 | func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) { | ||
| 1566 | p1 := portsmocks.NewMockPTY(t) | 1574 | p1 := portsmocks.NewMockPTY(t) | ||
| 1567 | - | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | 1575 | + | p1.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe() |
| 1568 | p2 := portsmocks.NewMockPTY(t) | 1576 | p2 := portsmocks.NewMockPTY(t) | ||
| 1569 | - | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Maybe() | 1577 | + | p2.EXPECT().Resize(domain.Size{Cols: 20, Rows: 4}).Return(nil).Maybe() |
| 1570 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | 1578 | d, sess, ac, _ := newManualSessionWithPTYs(t, p1) | ||
| 1571 | d.procComm = nil | 1579 | d.procComm = nil | ||
| 1572 | tb := sess.activeTab() | 1580 | tb := sess.activeTab() | ||
M internal/usecase/daemon/pane_actions.go +1 -1
| @@ -317,7 +317,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||
| 317 | 317 | target.tab.mu.Unlock() | |
| 318 | 318 | if newFocus != oldFocus { | |
| 319 | 319 | d.exitCopyMode(ac) | |
| 320 | - | if hasPlacement && pl.TitleBar.Height > 0 { | |
| 320 | + | if hasPlacement && pl.InStack { | |
| 321 | 321 | d.refreshPaneTitleOnFocus(sess, newFocus) | |
| 322 | 322 | } | |
| 323 | 323 | } | |
| @@ -317,7 +317,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio | |||||
| 317 | target.tab.mu.Unlock() | 317 | target.tab.mu.Unlock() | ||
| 318 | if newFocus != oldFocus { | 318 | if newFocus != oldFocus { | ||
| 319 | d.exitCopyMode(ac) | 319 | d.exitCopyMode(ac) | ||
| 320 | - | if hasPlacement && pl.TitleBar.Height > 0 { | 320 | + | if hasPlacement && pl.InStack { |
| 321 | d.refreshPaneTitleOnFocus(sess, newFocus) | 321 | d.refreshPaneTitleOnFocus(sess, newFocus) | ||
| 322 | } | 322 | } | ||
| 323 | } | 323 | } | ||
M internal/usecase/daemon/pane_actions_test.go +5 -5
| @@ -67,7 +67,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||
| 67 | 67 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"} | |
| 68 | 68 | tb.nextPaneID = 3 | |
| 69 | 69 | newPTY := portsmocks.NewMockPTY(t) | |
| 70 | - | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 70 | + | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Once() | |
| 71 | 71 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 72 | 72 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | |
| 73 | 73 | ||
| @@ -79,7 +79,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||
| 79 | 79 | require.True(t, ok) | |
| 80 | 80 | require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus) | |
| 81 | 81 | require.Len(t, tb.panes, 3) | |
| 82 | - | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, placementContent(placements, "pane-2")) | |
| 82 | + | require.Equal(t, domain.Rect{Y: 1, Width: 20, Height: 3}, placementContent(placements, "pane-2")) | |
| 83 | 83 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3")) | |
| 84 | 84 | } | |
| 85 | 85 | ||
| @@ -242,7 +242,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |||
| 242 | 242 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | |
| 243 | 243 | newPTY := portsmocks.NewMockPTY(t) | |
| 244 | 244 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 245 | - | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once() | |
| 245 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | |
| 246 | 246 | ||
| 247 | 247 | require.NoError(t, d.stackPane(sess, nil)) | |
| 248 | 248 | tb := sess.activeTab() | |
| @@ -262,9 +262,9 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||
| 262 | 262 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | |
| 263 | 263 | newPTY := portsmocks.NewMockPTY(t) | |
| 264 | 264 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | |
| 265 | - | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once() | |
| 265 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 2}).Return(newPTY, nil).Once() | |
| 266 | 266 | require.NoError(t, d.stackPane(sess, nil)) | |
| 267 | - | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once() | |
| 267 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | |
| 268 | 268 | ||
| 269 | 269 | require.NoError(t, d.focusDir(sess, nil, layout.Up)) | |
| 270 | 270 | ||
| @@ -67,7 +67,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||||
| 67 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"} | 67 | tb.tree = &layout.Tree{Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2")}, Expanded: "pane-2"}, Focus: "pane-2"} | ||
| 68 | tb.nextPaneID = 3 | 68 | tb.nextPaneID = 3 | ||
| 69 | newPTY := portsmocks.NewMockPTY(t) | 69 | newPTY := portsmocks.NewMockPTY(t) | ||
| 70 | - | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() | 70 | + | stackPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 3}).Return(nil).Once() |
| 71 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 71 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 72 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | 72 | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() | ||
| 73 | 73 | ||||
| @@ -79,7 +79,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) { | |||||
| 79 | require.True(t, ok) | 79 | require.True(t, ok) | ||
| 80 | require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus) | 80 | require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus) | ||
| 81 | require.Len(t, tb.panes, 3) | 81 | require.Len(t, tb.panes, 3) | ||
| 82 | - | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, placementContent(placements, "pane-2")) | 82 | + | require.Equal(t, domain.Rect{Y: 1, Width: 20, Height: 3}, placementContent(placements, "pane-2")) |
| 83 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3")) | 83 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3")) | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| @@ -242,7 +242,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) { | |||||
| 242 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | 242 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5}) | ||
| 243 | newPTY := portsmocks.NewMockPTY(t) | 243 | newPTY := portsmocks.NewMockPTY(t) | ||
| 244 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 244 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 245 | - | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 3}).Return(newPTY, nil).Once() | 245 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once() |
| 246 | 246 | ||||
| 247 | require.NoError(t, d.stackPane(sess, nil)) | 247 | require.NoError(t, d.stackPane(sess, nil)) | ||
| 248 | tb := sess.activeTab() | 248 | tb := sess.activeTab() | ||
| @@ -262,9 +262,9 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) { | |||||
| 262 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | 262 | d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3}) | ||
| 263 | newPTY := portsmocks.NewMockPTY(t) | 263 | newPTY := portsmocks.NewMockPTY(t) | ||
| 264 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | 264 | newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe() | ||
| 265 | - | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 1}).Return(newPTY, nil).Once() | 265 | + | factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 2}).Return(newPTY, nil).Once() |
| 266 | require.NoError(t, d.stackPane(sess, nil)) | 266 | require.NoError(t, d.stackPane(sess, nil)) | ||
| 267 | - | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 1}).Return(nil).Once() | 267 | + | oldPTY.EXPECT().Resize(domain.Size{Cols: 20, Rows: 2}).Return(nil).Once() |
| 268 | 268 | ||||
| 269 | require.NoError(t, d.focusDir(sess, nil, layout.Up)) | 269 | require.NoError(t, d.focusDir(sess, nil, layout.Up)) | ||
| 270 | 270 | ||||
M internal/usecase/daemon/picker_test.go +1 -2
| @@ -882,8 +882,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||
| 882 | 882 | require.Equal(t, 'L', preview.Rows[0][0].Rune, "left pane content should remain visible") | |
| 883 | 883 | require.Equal(t, '│', preview.Rows[0][20].Rune, "split divider should be included") | |
| 884 | 884 | require.Equal(t, "two", rowText(preview.Rows[0][21:24]), "collapsed stack title bar should be included") | |
| 885 | - | require.Equal(t, "three", rowText(preview.Rows[1][21:26]), "expanded stack title bar should be included") | |
| 886 | - | require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included") | |
| 885 | + | require.Equal(t, 'R', preview.Rows[1][21].Rune, "expanded stack member draws no title bar; its content starts where the title bar used to be") | |
| 887 | 886 | } | |
| 888 | 887 | ||
| 889 | 888 | func TestPickerModalGeometry(t *testing.T) { | |
| @@ -882,8 +882,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||||
| 882 | require.Equal(t, 'L', preview.Rows[0][0].Rune, "left pane content should remain visible") | 882 | require.Equal(t, 'L', preview.Rows[0][0].Rune, "left pane content should remain visible") | ||
| 883 | require.Equal(t, '│', preview.Rows[0][20].Rune, "split divider should be included") | 883 | require.Equal(t, '│', preview.Rows[0][20].Rune, "split divider should be included") | ||
| 884 | require.Equal(t, "two", rowText(preview.Rows[0][21:24]), "collapsed stack title bar should be included") | 884 | require.Equal(t, "two", rowText(preview.Rows[0][21:24]), "collapsed stack title bar should be included") | ||
| 885 | - | require.Equal(t, "three", rowText(preview.Rows[1][21:26]), "expanded stack title bar should be included") | 885 | + | require.Equal(t, 'R', preview.Rows[1][21].Rune, "expanded stack member draws no title bar; its content starts where the title bar used to be") |
| 886 | - | require.Equal(t, 'R', preview.Rows[2][21].Rune, "expanded stacked pane content should be included") | |||
| 887 | } | 886 | } | ||
| 888 | 887 | ||||
| 889 | func TestPickerModalGeometry(t *testing.T) { | 888 | func TestPickerModalGeometry(t *testing.T) { | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -5
| @@ -167,10 +167,7 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||
| 167 | 167 | collapsedTitle := out.frame.At(0, 1) | |
| 168 | 168 | require.Equal(t, 'c', collapsedTitle.Rune) | |
| 169 | 169 | require.True(t, collapsedTitle.Style.HasForegroundRGB, "collapsed title bar must use dimmed chrome") | |
| 170 | - | focusedTitle := out.frame.At(0, 2) | |
| 171 | - | require.Equal(t, 's', focusedTitle.Rune) | |
| 172 | - | require.True(t, focusedTitle.Style.Inverse || focusedTitle.Style.HasBackgroundRGB, "focused title bar must use accent chrome") | |
| 173 | - | require.Equal(t, 'E', out.frame.At(0, 3).Rune, "expanded pane content must be visible below its title bar") | |
| 170 | + | require.Equal(t, 'E', out.frame.At(0, 2).Rune, "expanded pane must draw no title row; its content starts where the title bar used to be") | |
| 174 | 171 | require.Equal(t, before, state, "composition must not mutate the captured source frame") | |
| 175 | 172 | } | |
| 176 | 173 | ||
| @@ -241,7 +238,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured | |||
| 241 | 238 | first, second := layout.PaneID("first"), layout.PaneID("second") | |
| 242 | 239 | placements := []layout.Placement{ | |
| 243 | 240 | {ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 244 | - | {ID: second, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Content: domain.Rect{Y: 2, Width: 20, Height: 3}, InStack: true}, | |
| 241 | + | {ID: second, Content: domain.Rect{Y: 1, Width: 20, Height: 4}, InStack: true}, | |
| 245 | 242 | } | |
| 246 | 243 | return capturedRenderState{ | |
| 247 | 244 | reset: reset, | |
| @@ -167,10 +167,7 @@ func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||||
| 167 | collapsedTitle := out.frame.At(0, 1) | 167 | collapsedTitle := out.frame.At(0, 1) | ||
| 168 | require.Equal(t, 'c', collapsedTitle.Rune) | 168 | require.Equal(t, 'c', collapsedTitle.Rune) | ||
| 169 | require.True(t, collapsedTitle.Style.HasForegroundRGB, "collapsed title bar must use dimmed chrome") | 169 | require.True(t, collapsedTitle.Style.HasForegroundRGB, "collapsed title bar must use dimmed chrome") | ||
| 170 | - | focusedTitle := out.frame.At(0, 2) | 170 | + | require.Equal(t, 'E', out.frame.At(0, 2).Rune, "expanded pane must draw no title row; its content starts where the title bar used to be") |
| 171 | - | require.Equal(t, 's', focusedTitle.Rune) | |||
| 172 | - | require.True(t, focusedTitle.Style.Inverse || focusedTitle.Style.HasBackgroundRGB, "focused title bar must use accent chrome") | |||
| 173 | - | require.Equal(t, 'E', out.frame.At(0, 3).Rune, "expanded pane content must be visible below its title bar") | |||
| 174 | require.Equal(t, before, state, "composition must not mutate the captured source frame") | 171 | require.Equal(t, before, state, "composition must not mutate the captured source frame") | ||
| 175 | } | 172 | } | ||
| 176 | 173 | ||||
| @@ -241,7 +238,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured | |||||
| 241 | first, second := layout.PaneID("first"), layout.PaneID("second") | 238 | first, second := layout.PaneID("first"), layout.PaneID("second") | ||
| 242 | placements := []layout.Placement{ | 239 | placements := []layout.Placement{ | ||
| 243 | {ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | 240 | {ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | ||
| 244 | - | {ID: second, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Content: domain.Rect{Y: 2, Width: 20, Height: 3}, InStack: true}, | 241 | + | {ID: second, Content: domain.Rect{Y: 1, Width: 20, Height: 4}, InStack: true}, |
| 245 | } | 242 | } | ||
| 246 | return capturedRenderState{ | 243 | return capturedRenderState{ | ||
| 247 | reset: reset, | 244 | reset: reset, | ||
M internal/usecase/daemon/theme_cache_test.go +1 -1
| @@ -100,5 +100,5 @@ func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |||
| 100 | 100 | require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged") | |
| 101 | 101 | require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input") | |
| 102 | 102 | require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer") | |
| 103 | - | require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged") | |
| 103 | + | require.Equal(t, 'E', titleOut.frame.At(0, 2).Rune, "pane content must remain unchanged") | |
| 104 | 104 | } | |
| @@ -100,5 +100,5 @@ func TestChromeDimmersUseNeutralInputsOutsideAccentRamp(t *testing.T) { | |||||
| 100 | require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged") | 100 | require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged") | ||
| 101 | require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input") | 101 | require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input") | ||
| 102 | require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer") | 102 | require.NotEqual(t, accentDerived, titleOut.frame.At(0, 1).Style, "inactive title must exclude accent-ramp styles from Dimmer") | ||
| 103 | - | require.Equal(t, 'E', titleOut.frame.At(0, 3).Rune, "pane content must remain unchanged") | 103 | + | require.Equal(t, 'E', titleOut.frame.At(0, 2).Rune, "pane content must remain unchanged") |
| 104 | } | 104 | } | ||
M internal/usecase/layout/geometry.go +7 -8
| @@ -95,23 +95,22 @@ func solveStack(n *Node, r domain.Rect, out *[]Placement) bool { | |||
| 95 | 95 | if idx < 0 { | |
| 96 | 96 | return false | |
| 97 | 97 | } | |
| 98 | - | contentRows := r.Height - count | |
| 98 | + | contentRows := r.Height - (count - 1) | |
| 99 | 99 | for i, child := range n.Children { | |
| 100 | 100 | if i < idx { | |
| 101 | - | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: r.Y + i, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |
| 101 | + | titleY := r.Y + i | |
| 102 | + | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |
| 102 | 103 | continue | |
| 103 | 104 | } | |
| 104 | 105 | if i == idx { | |
| 105 | - | titleY := r.Y + i | |
| 106 | 106 | *out = append(*out, Placement{ | |
| 107 | - | ID: child.Leaf, | |
| 108 | - | TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, | |
| 109 | - | Content: domain.Rect{X: r.X, Y: titleY + 1, Width: r.Width, Height: contentRows}, | |
| 110 | - | InStack: true, | |
| 107 | + | ID: child.Leaf, | |
| 108 | + | Content: domain.Rect{X: r.X, Y: r.Y + idx, Width: r.Width, Height: contentRows}, | |
| 109 | + | InStack: true, | |
| 111 | 110 | }) | |
| 112 | 111 | continue | |
| 113 | 112 | } | |
| 114 | - | titleY := r.Y + idx + 1 + contentRows + (i - idx - 1) | |
| 113 | + | titleY := r.Y + idx + contentRows + (i - idx - 1) | |
| 115 | 114 | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |
| 116 | 115 | } | |
| 117 | 116 | return true | |
| @@ -95,23 +95,22 @@ func solveStack(n *Node, r domain.Rect, out *[]Placement) bool { | |||||
| 95 | if idx < 0 { | 95 | if idx < 0 { | ||
| 96 | return false | 96 | return false | ||
| 97 | } | 97 | } | ||
| 98 | - | contentRows := r.Height - count | 98 | + | contentRows := r.Height - (count - 1) |
| 99 | for i, child := range n.Children { | 99 | for i, child := range n.Children { | ||
| 100 | if i < idx { | 100 | if i < idx { | ||
| 101 | - | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: r.Y + i, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | 101 | + | titleY := r.Y + i |
| 102 | + | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | |||
| 102 | continue | 103 | continue | ||
| 103 | } | 104 | } | ||
| 104 | if i == idx { | 105 | if i == idx { | ||
| 105 | - | titleY := r.Y + i | |||
| 106 | *out = append(*out, Placement{ | 106 | *out = append(*out, Placement{ | ||
| 107 | - | ID: child.Leaf, | 107 | + | ID: child.Leaf, |
| 108 | - | TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, | 108 | + | Content: domain.Rect{X: r.X, Y: r.Y + idx, Width: r.Width, Height: contentRows}, |
| 109 | - | Content: domain.Rect{X: r.X, Y: titleY + 1, Width: r.Width, Height: contentRows}, | 109 | + | InStack: true, |
| 110 | - | InStack: true, | |||
| 111 | }) | 110 | }) | ||
| 112 | continue | 111 | continue | ||
| 113 | } | 112 | } | ||
| 114 | - | titleY := r.Y + idx + 1 + contentRows + (i - idx - 1) | 113 | + | titleY := r.Y + idx + contentRows + (i - idx - 1) |
| 115 | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | 114 | *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true}) | ||
| 116 | } | 115 | } | ||
| 117 | return true | 116 | return true | ||
M internal/usecase/layout/layout_test.go +74 -2
| @@ -65,7 +65,7 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||
| 65 | 65 | require.True(t, ok) | |
| 66 | 66 | require.Equal(t, []Placement{ | |
| 67 | 67 | {ID: "a", TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 68 | - | {ID: "b", Content: domain.Rect{Y: 2, Width: 20, Height: 1}, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, InStack: true}, | |
| 68 | + | {ID: "b", Content: domain.Rect{Y: 1, Width: 20, Height: 2}, InStack: true}, | |
| 69 | 69 | {ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 70 | 70 | }, got) | |
| 71 | 71 | ||
| @@ -73,6 +73,78 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||
| 73 | 73 | require.False(t, ok) | |
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | + | func TestSolveStackExpandedMemberHasNoTitleBar(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | area := domain.Rect{X: 5, Y: 2, Width: 20, Height: 5} | |
| 79 | + | tests := []struct { | |
| 80 | + | name string | |
| 81 | + | expanded PaneID | |
| 82 | + | want []Placement | |
| 83 | + | }{ | |
| 84 | + | { | |
| 85 | + | name: "edge member expanded", | |
| 86 | + | expanded: "a", | |
| 87 | + | want: []Placement{ | |
| 88 | + | {ID: "a", Content: domain.Rect{X: 5, Y: 2, Width: 20, Height: 3}, InStack: true}, | |
| 89 | + | {ID: "b", TitleBar: domain.Rect{X: 5, Y: 5, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 90 | + | {ID: "c", TitleBar: domain.Rect{X: 5, Y: 6, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 91 | + | }, | |
| 92 | + | }, | |
| 93 | + | { | |
| 94 | + | name: "middle member expanded", | |
| 95 | + | expanded: "b", | |
| 96 | + | want: []Placement{ | |
| 97 | + | {ID: "a", TitleBar: domain.Rect{X: 5, Y: 2, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 98 | + | {ID: "b", Content: domain.Rect{X: 5, Y: 3, Width: 20, Height: 3}, InStack: true}, | |
| 99 | + | {ID: "c", TitleBar: domain.Rect{X: 5, Y: 6, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 100 | + | }, | |
| 101 | + | }, | |
| 102 | + | { | |
| 103 | + | name: "other edge member expanded", | |
| 104 | + | expanded: "c", | |
| 105 | + | want: []Placement{ | |
| 106 | + | {ID: "a", TitleBar: domain.Rect{X: 5, Y: 2, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 107 | + | {ID: "b", TitleBar: domain.Rect{X: 5, Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |
| 108 | + | {ID: "c", Content: domain.Rect{X: 5, Y: 4, Width: 20, Height: 3}, InStack: true}, | |
| 109 | + | }, | |
| 110 | + | }, | |
| 111 | + | } | |
| 112 | + | for _, tt := range tests { | |
| 113 | + | t.Run(tt.name, func(t *testing.T) { | |
| 114 | + | stack := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: tt.expanded} | |
| 115 | + | got, ok := Solve(stack, area) | |
| 116 | + | require.True(t, ok) | |
| 117 | + | require.Equal(t, tt.want, got) | |
| 118 | + | ||
| 119 | + | // Invariant: total consumed rows equal area.Height, and rows are | |
| 120 | + | // contiguous and non-overlapping across title bars and content. | |
| 121 | + | rows := make(map[int]PaneID, area.Height) | |
| 122 | + | for _, p := range got { | |
| 123 | + | if p.TitleBar.Height > 0 { | |
| 124 | + | require.Zero(t, p.Content.Height, "expanded member must not have both a title bar and content") | |
| 125 | + | for y := p.TitleBar.Y; y < p.TitleBar.Y+p.TitleBar.Height; y++ { | |
| 126 | + | _, dup := rows[y] | |
| 127 | + | require.False(t, dup, "row %d already occupied", y) | |
| 128 | + | rows[y] = p.ID | |
| 129 | + | } | |
| 130 | + | } else { | |
| 131 | + | require.Equal(t, tt.expanded, p.ID, "only the expanded member may omit its title bar") | |
| 132 | + | for y := p.Content.Y; y < p.Content.Y+p.Content.Height; y++ { | |
| 133 | + | _, dup := rows[y] | |
| 134 | + | require.False(t, dup, "row %d already occupied", y) | |
| 135 | + | rows[y] = p.ID | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | require.Len(t, rows, area.Height) | |
| 140 | + | for y := area.Y; y < area.Y+area.Height; y++ { | |
| 141 | + | _, ok := rows[y] | |
| 142 | + | require.True(t, ok, "row %d not covered", y) | |
| 143 | + | } | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 76 | 148 | func TestSplitStackedLeafSplitsWholeStack(t *testing.T) { | |
| 77 | 149 | t.Parallel() | |
| 78 | 150 | tr := &Tree{ | |
| @@ -85,7 +157,7 @@ func TestSplitStackedLeafSplitsWholeStack(t *testing.T) { | |||
| 85 | 157 | placements, ok := Solve(tr.Root, area) | |
| 86 | 158 | require.True(t, ok) | |
| 87 | 159 | require.Equal(t, PaneID("three"), tr.Focus) | |
| 88 | - | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, contents(placements)["two"], "existing stack should remain visible on the left") | |
| 160 | + | require.Equal(t, domain.Rect{Y: 1, Width: 20, Height: 3}, contents(placements)["two"], "existing stack should remain visible on the left") | |
| 89 | 161 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, contents(placements)["three"], "new split pane should appear to the right of the stack") | |
| 90 | 162 | } | |
| 91 | 163 | ||
| @@ -65,7 +65,7 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||||
| 65 | require.True(t, ok) | 65 | require.True(t, ok) | ||
| 66 | require.Equal(t, []Placement{ | 66 | require.Equal(t, []Placement{ | ||
| 67 | {ID: "a", TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | 67 | {ID: "a", TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true}, | ||
| 68 | - | {ID: "b", Content: domain.Rect{Y: 2, Width: 20, Height: 1}, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, InStack: true}, | 68 | + | {ID: "b", Content: domain.Rect{Y: 1, Width: 20, Height: 2}, InStack: true}, |
| 69 | {ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | 69 | {ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | ||
| 70 | }, got) | 70 | }, got) | ||
| 71 | 71 | ||||
| @@ -73,6 +73,78 @@ func TestSolveStackFitAndOverflow(t *testing.T) { | |||||
| 73 | require.False(t, ok) | 73 | require.False(t, ok) | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | func TestSolveStackExpandedMemberHasNoTitleBar(t *testing.T) { | |||
| 77 | + | t.Parallel() | |||
| 78 | + | area := domain.Rect{X: 5, Y: 2, Width: 20, Height: 5} | |||
| 79 | + | tests := []struct { | |||
| 80 | + | name string | |||
| 81 | + | expanded PaneID | |||
| 82 | + | want []Placement | |||
| 83 | + | }{ | |||
| 84 | + | { | |||
| 85 | + | name: "edge member expanded", | |||
| 86 | + | expanded: "a", | |||
| 87 | + | want: []Placement{ | |||
| 88 | + | {ID: "a", Content: domain.Rect{X: 5, Y: 2, Width: 20, Height: 3}, InStack: true}, | |||
| 89 | + | {ID: "b", TitleBar: domain.Rect{X: 5, Y: 5, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 90 | + | {ID: "c", TitleBar: domain.Rect{X: 5, Y: 6, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 91 | + | }, | |||
| 92 | + | }, | |||
| 93 | + | { | |||
| 94 | + | name: "middle member expanded", | |||
| 95 | + | expanded: "b", | |||
| 96 | + | want: []Placement{ | |||
| 97 | + | {ID: "a", TitleBar: domain.Rect{X: 5, Y: 2, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 98 | + | {ID: "b", Content: domain.Rect{X: 5, Y: 3, Width: 20, Height: 3}, InStack: true}, | |||
| 99 | + | {ID: "c", TitleBar: domain.Rect{X: 5, Y: 6, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 100 | + | }, | |||
| 101 | + | }, | |||
| 102 | + | { | |||
| 103 | + | name: "other edge member expanded", | |||
| 104 | + | expanded: "c", | |||
| 105 | + | want: []Placement{ | |||
| 106 | + | {ID: "a", TitleBar: domain.Rect{X: 5, Y: 2, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 107 | + | {ID: "b", TitleBar: domain.Rect{X: 5, Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true}, | |||
| 108 | + | {ID: "c", Content: domain.Rect{X: 5, Y: 4, Width: 20, Height: 3}, InStack: true}, | |||
| 109 | + | }, | |||
| 110 | + | }, | |||
| 111 | + | } | |||
| 112 | + | for _, tt := range tests { | |||
| 113 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 114 | + | stack := &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), NewLeaf("b"), NewLeaf("c")}, Expanded: tt.expanded} | |||
| 115 | + | got, ok := Solve(stack, area) | |||
| 116 | + | require.True(t, ok) | |||
| 117 | + | require.Equal(t, tt.want, got) | |||
| 118 | + | ||||
| 119 | + | // Invariant: total consumed rows equal area.Height, and rows are | |||
| 120 | + | // contiguous and non-overlapping across title bars and content. | |||
| 121 | + | rows := make(map[int]PaneID, area.Height) | |||
| 122 | + | for _, p := range got { | |||
| 123 | + | if p.TitleBar.Height > 0 { | |||
| 124 | + | require.Zero(t, p.Content.Height, "expanded member must not have both a title bar and content") | |||
| 125 | + | for y := p.TitleBar.Y; y < p.TitleBar.Y+p.TitleBar.Height; y++ { | |||
| 126 | + | _, dup := rows[y] | |||
| 127 | + | require.False(t, dup, "row %d already occupied", y) | |||
| 128 | + | rows[y] = p.ID | |||
| 129 | + | } | |||
| 130 | + | } else { | |||
| 131 | + | require.Equal(t, tt.expanded, p.ID, "only the expanded member may omit its title bar") | |||
| 132 | + | for y := p.Content.Y; y < p.Content.Y+p.Content.Height; y++ { | |||
| 133 | + | _, dup := rows[y] | |||
| 134 | + | require.False(t, dup, "row %d already occupied", y) | |||
| 135 | + | rows[y] = p.ID | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | require.Len(t, rows, area.Height) | |||
| 140 | + | for y := area.Y; y < area.Y+area.Height; y++ { | |||
| 141 | + | _, ok := rows[y] | |||
| 142 | + | require.True(t, ok, "row %d not covered", y) | |||
| 143 | + | } | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 76 | func TestSplitStackedLeafSplitsWholeStack(t *testing.T) { | 148 | func TestSplitStackedLeafSplitsWholeStack(t *testing.T) { | ||
| 77 | t.Parallel() | 149 | t.Parallel() | ||
| 78 | tr := &Tree{ | 150 | tr := &Tree{ | ||
| @@ -85,7 +157,7 @@ func TestSplitStackedLeafSplitsWholeStack(t *testing.T) { | |||||
| 85 | placements, ok := Solve(tr.Root, area) | 157 | placements, ok := Solve(tr.Root, area) | ||
| 86 | require.True(t, ok) | 158 | require.True(t, ok) | ||
| 87 | require.Equal(t, PaneID("three"), tr.Focus) | 159 | require.Equal(t, PaneID("three"), tr.Focus) | ||
| 88 | - | require.Equal(t, domain.Rect{Y: 2, Width: 20, Height: 2}, contents(placements)["two"], "existing stack should remain visible on the left") | 160 | + | require.Equal(t, domain.Rect{Y: 1, Width: 20, Height: 3}, contents(placements)["two"], "existing stack should remain visible on the left") |
| 89 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, contents(placements)["three"], "new split pane should appear to the right of the stack") | 161 | require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, contents(placements)["three"], "new split pane should appear to the right of the stack") | ||
| 90 | } | 162 | } | ||
| 91 | 163 | ||||