detached c240dab

Merge pull request #130 from bnema/fix/stacked-pane-hide-active-title

fix(layout): hide the active stacked pane's title bar
commit
c240dab733be700b9594ec3cb50a3a3ad43588c2
author
bnema · · 2026-07-25 04:03 UTC
committer
GitHub · · 2026-07-25 04:03 UTC
merge of
3694921 + ed8e7b6 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +110 -35
M internal/usecase/daemon/control.go +1 -1
@@ -356,7 +356,7 @@ func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *att
356356 tb.mu.Unlock()
357357 if newFocus != request.target.pane.id {
358358 d.exitCopyMode(ac)
359- if hasPlacement && pl.TitleBar.Height > 0 {
359+ if hasPlacement && pl.InStack {
360360 d.refreshPaneTitleOnFocus(request.target.session, newFocus)
361361 }
362362 }
@@ -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)
13871387 ac.overlays.copyMu.Unlock()
13881388 }
13891389
1390-func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing.T) {
1390+func TestPressAtFormerStackTitleRowIsTreatedAsExpandedContent(t *testing.T) {
13911391 p, release := newBlockingPTY(t)
13921392 defer release()
13931393 d, sess, ac, _ := newManualSessionWithPTYs(t, p)
13941394 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})
13961396 p3 := newPane("pane-3", nil, domain.Size{Cols: 20, Rows: 1})
13971397 tb.mu.Lock()
13981398 tb.size = domain.Size{Cols: 41, Rows: 10}
@@ -1405,25 +1405,33 @@ func TestFocusedSplitTitleBarPressInvalidatesFreshPointerBeforeMotion(t *testing
14051405 tb.panes["pane-3"] = p3
14061406 tb.mu.Unlock()
14071407
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.
14111412 d.handleInput(sess, ac, []byte("\x1b[<0;22;3M"))
14121413 ac.overlays.copyMu.Lock()
14131414 require.True(t, ac.overlays.copyPointer.valid)
14141415 epoch := ac.overlays.copyPointerEpoch
14151416 ac.overlays.copyMu.Unlock()
14161417
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.
14171423 d.handleInput(sess, ac, []byte("\x1b[<0;22;2M"))
14181424 ac.overlays.copyMu.Lock()
1419- require.False(t, ac.overlays.copyPointer.valid)
1425+ require.True(t, ac.overlays.copyPointer.valid)
14201426 require.Greater(t, ac.overlays.copyPointerEpoch, epoch)
14211427 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")
14231429
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.
14241432 d.handleInput(sess, ac, []byte("\x1b[<32;22;4M"))
14251433 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")
14271435 ac.overlays.copyMu.Unlock()
14281436 }
14291437
@@ -1564,9 +1572,9 @@ func TestMouseGatedWhileNoticesOverlayActive(t *testing.T) {
15641572
15651573 func TestMouseCollapsedStackBarExpandsAndFocuses(t *testing.T) {
15661574 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()
15681576 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()
15701578 d, sess, ac, _ := newManualSessionWithPTYs(t, p1)
15711579 d.procComm = nil
15721580 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"] = p31405 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 already1408+ // 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 rather1409+ // 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.copyPointerEpoch1415 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 = nil1579 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
317317 target.tab.mu.Unlock()
318318 if newFocus != oldFocus {
319319 d.exitCopyMode(ac)
320- if hasPlacement && pl.TitleBar.Height > 0 {
320+ if hasPlacement && pl.InStack {
321321 d.refreshPaneTitleOnFocus(sess, newFocus)
322322 }
323323 }
@@ -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) {
6767 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"}
6868 tb.nextPaneID = 3
6969 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()
7171 newPTY.EXPECT().Read(mock.Anything).RunAndReturn(blockingRead(t)).Maybe()
7272 factory.EXPECT().Open(mock.Anything, "/bin/sh", []string(nil), mock.Anything, "/work", domain.Size{Cols: 20, Rows: 4}).Return(newPTY, nil).Once()
7373
@@ -79,7 +79,7 @@ func TestSplitPaneRightFromStackSplitsWholeStack(t *testing.T) {
7979 require.True(t, ok)
8080 require.Equal(t, layout.PaneID("pane-3"), tb.tree.Focus)
8181 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"))
8383 require.Equal(t, domain.Rect{X: 21, Width: 20, Height: 4}, placementContent(placements, "pane-3"))
8484 }
8585
@@ -242,7 +242,7 @@ func TestStackPaneCreatesStackAndToggleRestoresSplit(t *testing.T) {
242242 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 5})
243243 newPTY := portsmocks.NewMockPTY(t)
244244 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()
246246
247247 require.NoError(t, d.stackPane(sess, nil))
248248 tb := sess.activeTab()
@@ -262,9 +262,9 @@ func TestStackFocusWalkExpandsAndOverflowRefuses(t *testing.T) {
262262 d, sess, oldPTY, factory := newSplitTestDaemon(t, domain.Size{Cols: 20, Rows: 3})
263263 newPTY := portsmocks.NewMockPTY(t)
264264 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()
266266 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()
268268
269269 require.NoError(t, d.focusDir(sess, nil, layout.Up))
270270
@@ -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 = 368 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) {
882882 require.Equal(t, 'L', preview.Rows[0][0].Rune, "left pane content should remain visible")
883883 require.Equal(t, '│', preview.Rows[0][20].Rune, "split divider should be included")
884884 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")
887886 }
888887
889888 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) {
167167 collapsedTitle := out.frame.At(0, 1)
168168 require.Equal(t, 'c', collapsedTitle.Rune)
169169 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")
174171 require.Equal(t, before, state, "composition must not mutate the captured source frame")
175172 }
176173
@@ -241,7 +238,7 @@ func cachedStackTitleState(title string, generation uint64, reset bool) captured
241238 first, second := layout.PaneID("first"), layout.PaneID("second")
242239 placements := []layout.Placement{
243240 {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},
245242 }
246243 return capturedRenderState{
247244 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) {
100100 require.Equal(t, 'i', titleOut.frame.At(0, 1).Rune, "inactive title geometry must remain unchanged")
101101 require.Equal(t, want, titleOut.frame.At(0, 1).Style, "inactive title must dim a neutral input")
102102 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")
104104 }
@@ -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 {
9595 if idx < 0 {
9696 return false
9797 }
98- contentRows := r.Height - count
98+ contentRows := r.Height - (count - 1)
9999 for i, child := range n.Children {
100100 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})
102103 continue
103104 }
104105 if i == idx {
105- titleY := r.Y + i
106106 *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,
111110 })
112111 continue
113112 }
114- titleY := r.Y + idx + 1 + contentRows + (i - idx - 1)
113+ titleY := r.Y + idx + contentRows + (i - idx - 1)
115114 *out = append(*out, Placement{ID: child.Leaf, TitleBar: domain.Rect{X: r.X, Y: titleY, Width: r.Width, Height: 1}, Collapsed: true, InStack: true})
116115 }
117116 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 false96 return false
97 }97 }
98- contentRows := r.Height - count98+ 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 continue103 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 continue111 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 true116 return true
M internal/usecase/layout/layout_test.go +74 -2
@@ -65,7 +65,7 @@ func TestSolveStackFitAndOverflow(t *testing.T) {
6565 require.True(t, ok)
6666 require.Equal(t, []Placement{
6767 {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},
6969 {ID: "c", TitleBar: domain.Rect{Y: 3, Width: 20, Height: 1}, Collapsed: true, InStack: true},
7070 }, got)
7171
@@ -73,6 +73,78 @@ func TestSolveStackFitAndOverflow(t *testing.T) {
7373 require.False(t, ok)
7474 }
7575
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+
76148 func TestSplitStackedLeafSplitsWholeStack(t *testing.T) {
77149 t.Parallel()
78150 tr := &Tree{
@@ -85,7 +157,7 @@ func TestSplitStackedLeafSplitsWholeStack(t *testing.T) {
85157 placements, ok := Solve(tr.Root, area)
86158 require.True(t, ok)
87159 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")
89161 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")
90162 }
91163
@@ -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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close