fix(layout): hide the active stacked pane's title bar
In stacked mode solveStack gave every child a 1-row title bar, including the expanded member, so the active pane's title stayed visible and was only distinguished by style. Emit content only for the expanded member, starting on the row its title bar used to occupy, so the title reappears exactly when the pane collapses. Content rows become r.Height-(count-1), identical for whichever member is expanded, so walking the stack adds no extra PTY resize. The fit guard is unchanged. Two focus paths gated a title refresh on TitleBar.Height > 0, which is now always false for a newly focused stack member; gate on InStack to keep the existing refresh cadence.
- commit
- ed8e7b6cede7e1990cde6f96c1165efcd9346396
- author
- brice · · 2026-07-25 04:02 UTC
- parent
- bcb097b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
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 | ||||