fix(panes): align cursor with focused pane
- commit
- 58a748aa6eb0d8b019fb6b5aa9c3dad338fdab35
- author
- brice · · 2026-07-04 08:14 UTC
- parent
- c4a18a9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +74 -6
M internal/usecase/daemon/render.go +19 -6
| @@ -355,8 +355,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 355 | 355 | frame, damage = composePromptClientFrame(promptModel, frame, styles) | |
| 356 | 356 | ac.promptMu.Unlock() | |
| 357 | 357 | } | |
| 358 | + | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |
| 358 | 359 | p.mu.Lock() | |
| 359 | - | desiredCursor := desiredCursorOut(p.screen, copyActive || pickerActive || paletteActive || promptActive) | |
| 360 | + | desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || copyActive || pickerActive || paletteActive || promptActive) | |
| 360 | 361 | p.mu.Unlock() | |
| 361 | 362 | data, err := ac.rend.Draw(frame, damage) | |
| 362 | 363 | var cursorTail []byte | |
| @@ -388,17 +389,29 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 388 | 389 | } | |
| 389 | 390 | } | |
| 390 | 391 | ||
| 392 | + | func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) { | |
| 393 | + | if !layoutSnap.ok { | |
| 394 | + | return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0 | |
| 395 | + | } | |
| 396 | + | for _, pl := range layoutSnap.placements { | |
| 397 | + | if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |
| 398 | + | return pl.Content, true | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | return domain.Rect{}, false | |
| 402 | + | } | |
| 403 | + | ||
| 391 | 404 | // desiredCursorOut computes the terminal cursor state that should be shown to | |
| 392 | - | // the client for the current tab and overlay mode. | |
| 393 | - | func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut { | |
| 394 | - | if copyActive || !s.CursorVisible() { | |
| 405 | + | // the client for the current pane placement and overlay mode. | |
| 406 | + | func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { | |
| 407 | + | if hide || !s.CursorVisible() { | |
| 395 | 408 | return cursorOut{hidden: true} | |
| 396 | 409 | } | |
| 397 | 410 | style, ok := s.CursorStyle() | |
| 398 | 411 | if !ok { | |
| 399 | 412 | style = 5 | |
| 400 | 413 | } | |
| 401 | - | return cursorOut{row: s.CursorRow() + 1, col: s.CursorCol(), style: style, hasStyle: true} | |
| 414 | + | return cursorOut{row: content.Y + s.CursorRow() + 1, col: content.X + s.CursorCol(), style: style, hasStyle: true} | |
| 402 | 415 | } | |
| 403 | 416 | ||
| 404 | 417 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |
| @@ -537,7 +550,7 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||
| 537 | 550 | } | |
| 538 | 551 | placements = []layout.Placement{{ID: fallback.id, Content: area}} | |
| 539 | 552 | } | |
| 540 | - | drawDividers(frame, root, area, newThemeStyles(theme).border) | |
| 553 | + | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) | |
| 541 | 554 | var damage []renderer.Damage | |
| 542 | 555 | for _, pl := range placements { | |
| 543 | 556 | p := tb.panes[pl.ID] | |
| @@ -355,8 +355,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 355 | frame, damage = composePromptClientFrame(promptModel, frame, styles) | 355 | frame, damage = composePromptClientFrame(promptModel, frame, styles) | ||
| 356 | ac.promptMu.Unlock() | 356 | ac.promptMu.Unlock() | ||
| 357 | } | 357 | } | ||
| 358 | + | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |||
| 358 | p.mu.Lock() | 359 | p.mu.Lock() | ||
| 359 | - | desiredCursor := desiredCursorOut(p.screen, copyActive || pickerActive || paletteActive || promptActive) | 360 | + | desiredCursor := desiredCursorOut(p.screen, cursorContent, !cursorVisible || copyActive || pickerActive || paletteActive || promptActive) |
| 360 | p.mu.Unlock() | 361 | p.mu.Unlock() | ||
| 361 | data, err := ac.rend.Draw(frame, damage) | 362 | data, err := ac.rend.Draw(frame, damage) | ||
| 362 | var cursorTail []byte | 363 | var cursorTail []byte | ||
| @@ -388,17 +389,29 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 388 | } | 389 | } | ||
| 389 | } | 390 | } | ||
| 390 | 391 | ||||
| 392 | + | func focusedPaneContentRect(layoutSnap tabLayoutSnapshot, id layout.PaneID) (domain.Rect, bool) { | |||
| 393 | + | if !layoutSnap.ok { | |||
| 394 | + | return layoutSnap.area, layoutSnap.area.Width > 0 && layoutSnap.area.Height > 0 | |||
| 395 | + | } | |||
| 396 | + | for _, pl := range layoutSnap.placements { | |||
| 397 | + | if pl.ID == id && !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 { | |||
| 398 | + | return pl.Content, true | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | return domain.Rect{}, false | |||
| 402 | + | } | |||
| 403 | + | ||||
| 391 | // desiredCursorOut computes the terminal cursor state that should be shown to | 404 | // desiredCursorOut computes the terminal cursor state that should be shown to | ||
| 392 | - | // the client for the current tab and overlay mode. | 405 | + | // the client for the current pane placement and overlay mode. |
| 393 | - | func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut { | 406 | + | func desiredCursorOut(s *vt.Screen, content domain.Rect, hide bool) cursorOut { |
| 394 | - | if copyActive || !s.CursorVisible() { | 407 | + | if hide || !s.CursorVisible() { |
| 395 | return cursorOut{hidden: true} | 408 | return cursorOut{hidden: true} | ||
| 396 | } | 409 | } | ||
| 397 | style, ok := s.CursorStyle() | 410 | style, ok := s.CursorStyle() | ||
| 398 | if !ok { | 411 | if !ok { | ||
| 399 | style = 5 | 412 | style = 5 | ||
| 400 | } | 413 | } | ||
| 401 | - | return cursorOut{row: s.CursorRow() + 1, col: s.CursorCol(), style: style, hasStyle: true} | 414 | + | return cursorOut{row: content.Y + s.CursorRow() + 1, col: content.X + s.CursorCol(), style: style, hasStyle: true} |
| 402 | } | 415 | } | ||
| 403 | 416 | ||||
| 404 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | 417 | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | ||
| @@ -537,7 +550,7 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||||
| 537 | } | 550 | } | ||
| 538 | placements = []layout.Placement{{ID: fallback.id, Content: area}} | 551 | placements = []layout.Placement{{ID: fallback.id, Content: area}} | ||
| 539 | } | 552 | } | ||
| 540 | - | drawDividers(frame, root, area, newThemeStyles(theme).border) | 553 | + | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) |
| 541 | var damage []renderer.Damage | 554 | var damage []renderer.Damage | ||
| 542 | for _, pl := range placements { | 555 | for _, pl := range placements { | ||
| 543 | p := tb.panes[pl.ID] | 556 | p := tb.panes[pl.ID] | ||
M internal/usecase/daemon/render_test.go +55 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | + | "strconv" | |
| 9 | 10 | "strings" | |
| 10 | 11 | "sync/atomic" | |
| 11 | 12 | "testing" | |
| @@ -838,3 +839,57 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |||
| 838 | 839 | data = mustOutputData(t, sends) | |
| 839 | 840 | require.Contains(t, string(data), "\x1b[?25l") | |
| 840 | 841 | } | |
| 842 | + | ||
| 843 | + | func TestCursorTailUsesFocusedPanePlacement(t *testing.T) { | |
| 844 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 845 | + | win := sess.tabs[0] | |
| 846 | + | win.size = domain.Size{Cols: 80, Rows: 23} | |
| 847 | + | left := win.focusedPane() | |
| 848 | + | right := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23}) | |
| 849 | + | win.panes[right.id] = right | |
| 850 | + | win.tree = layout.NewTree(left.id) | |
| 851 | + | require.NoError(t, win.tree.Split(left.id, layout.Right, true, right.id, domain.Rect{Width: 80, Height: 23})) | |
| 852 | + | win.tree.Focus = right.id | |
| 853 | + | right.screen.Write([]byte("\x1b[2;3H")) | |
| 854 | + | placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23}) | |
| 855 | + | require.True(t, ok) | |
| 856 | + | rightContent := placementContent(placements, right.id) | |
| 857 | + | ||
| 858 | + | d.paint(sess, ac, true) | |
| 859 | + | data := mustOutputData(t, sends) | |
| 860 | + | want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1) | |
| 861 | + | require.Contains(t, string(data), want) | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) { | |
| 865 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |
| 866 | + | win := sess.tabs[0] | |
| 867 | + | win.size = domain.Size{Cols: 80, Rows: 23} | |
| 868 | + | one := win.focusedPane() | |
| 869 | + | two := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 20}) | |
| 870 | + | three := newPane("pane-3", nil, domain.Size{Cols: 80, Rows: 20}) | |
| 871 | + | win.panes[two.id] = two | |
| 872 | + | win.panes[three.id] = three | |
| 873 | + | win.tree = &layout.Tree{ | |
| 874 | + | Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{ | |
| 875 | + | layout.NewLeaf(one.id), | |
| 876 | + | layout.NewLeaf(two.id), | |
| 877 | + | layout.NewLeaf(three.id), | |
| 878 | + | }, Expanded: two.id}, | |
| 879 | + | Focus: two.id, | |
| 880 | + | } | |
| 881 | + | two.screen.Write([]byte("\x1b[2;3H")) | |
| 882 | + | placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23}) | |
| 883 | + | require.True(t, ok) | |
| 884 | + | twoContent := placementContent(placements, two.id) | |
| 885 | + | require.Greater(t, twoContent.Y, 0, "stack title bars should offset content") | |
| 886 | + | ||
| 887 | + | d.paint(sess, ac, true) | |
| 888 | + | data := mustOutputData(t, sends) | |
| 889 | + | want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1) | |
| 890 | + | require.Contains(t, string(data), want) | |
| 891 | + | } | |
| 892 | + | ||
| 893 | + | func cursorCSI(row, col int) string { | |
| 894 | + | return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H" | |
| 895 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "io" | 7 | "io" | ||
| 8 | "log/slog" | 8 | "log/slog" | ||
| 9 | + | "strconv" | |||
| 9 | "strings" | 10 | "strings" | ||
| 10 | "sync/atomic" | 11 | "sync/atomic" | ||
| 11 | "testing" | 12 | "testing" | ||
| @@ -838,3 +839,57 @@ func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |||||
| 838 | data = mustOutputData(t, sends) | 839 | data = mustOutputData(t, sends) | ||
| 839 | require.Contains(t, string(data), "\x1b[?25l") | 840 | require.Contains(t, string(data), "\x1b[?25l") | ||
| 840 | } | 841 | } | ||
| 842 | + | ||||
| 843 | + | func TestCursorTailUsesFocusedPanePlacement(t *testing.T) { | |||
| 844 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |||
| 845 | + | win := sess.tabs[0] | |||
| 846 | + | win.size = domain.Size{Cols: 80, Rows: 23} | |||
| 847 | + | left := win.focusedPane() | |||
| 848 | + | right := newPane("pane-2", nil, domain.Size{Cols: 39, Rows: 23}) | |||
| 849 | + | win.panes[right.id] = right | |||
| 850 | + | win.tree = layout.NewTree(left.id) | |||
| 851 | + | require.NoError(t, win.tree.Split(left.id, layout.Right, true, right.id, domain.Rect{Width: 80, Height: 23})) | |||
| 852 | + | win.tree.Focus = right.id | |||
| 853 | + | right.screen.Write([]byte("\x1b[2;3H")) | |||
| 854 | + | placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23}) | |||
| 855 | + | require.True(t, ok) | |||
| 856 | + | rightContent := placementContent(placements, right.id) | |||
| 857 | + | ||||
| 858 | + | d.paint(sess, ac, true) | |||
| 859 | + | data := mustOutputData(t, sends) | |||
| 860 | + | want := cursorCSI(rightContent.Y+right.screen.CursorRow()+2, rightContent.X+right.screen.CursorCol()+1) | |||
| 861 | + | require.Contains(t, string(data), want) | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | func TestCursorTailUsesExpandedStackContentPlacement(t *testing.T) { | |||
| 865 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, nil) | |||
| 866 | + | win := sess.tabs[0] | |||
| 867 | + | win.size = domain.Size{Cols: 80, Rows: 23} | |||
| 868 | + | one := win.focusedPane() | |||
| 869 | + | two := newPane("pane-2", nil, domain.Size{Cols: 80, Rows: 20}) | |||
| 870 | + | three := newPane("pane-3", nil, domain.Size{Cols: 80, Rows: 20}) | |||
| 871 | + | win.panes[two.id] = two | |||
| 872 | + | win.panes[three.id] = three | |||
| 873 | + | win.tree = &layout.Tree{ | |||
| 874 | + | Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{ | |||
| 875 | + | layout.NewLeaf(one.id), | |||
| 876 | + | layout.NewLeaf(two.id), | |||
| 877 | + | layout.NewLeaf(three.id), | |||
| 878 | + | }, Expanded: two.id}, | |||
| 879 | + | Focus: two.id, | |||
| 880 | + | } | |||
| 881 | + | two.screen.Write([]byte("\x1b[2;3H")) | |||
| 882 | + | placements, ok := layout.Solve(win.tree.Root, domain.Rect{Width: 80, Height: 23}) | |||
| 883 | + | require.True(t, ok) | |||
| 884 | + | twoContent := placementContent(placements, two.id) | |||
| 885 | + | require.Greater(t, twoContent.Y, 0, "stack title bars should offset content") | |||
| 886 | + | ||||
| 887 | + | d.paint(sess, ac, true) | |||
| 888 | + | data := mustOutputData(t, sends) | |||
| 889 | + | want := cursorCSI(twoContent.Y+two.screen.CursorRow()+2, twoContent.X+two.screen.CursorCol()+1) | |||
| 890 | + | require.Contains(t, string(data), want) | |||
| 891 | + | } | |||
| 892 | + | ||||
| 893 | + | func cursorCSI(row, col int) string { | |||
| 894 | + | return "\x1b[" + strconv.Itoa(row) + ";" + strconv.Itoa(col) + "H" | |||
| 895 | + | } | |||