detached ee31e8b

fix(overlay): keep the app-set cursor shape on modal carets when the pane cursor is hidden

commit
ee31e8beacd50877843178ab650faa945aeb0dd7
author
brice · · 2026-10-07 20:35 UTC
parent
28cbe10
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
feat/overlay-input-caret
2 files · +13 -7
M internal/usecase/daemon/overlay_cursor_test.go +10 -4
@@ -15,11 +15,11 @@ func TestOverlayInputCursor(t *testing.T) {
1515 return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true}
1616 }
1717 notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}
18- bar := cursorOut{valid: true, row: 5, col: 5, style: 6, hasStyle: true}
18+ bar := capturedCursorInputs{style: 6, hasStyle: true, visible: true, renderable: true}
1919 for _, tt := range []struct {
2020 name string
2121 overlays capturedOverlayRenderState
22- pane cursorOut
22+ pane capturedCursorInputs
2323 want cursorOut
2424 }{
2525 {
@@ -29,9 +29,15 @@ func TestOverlayInputCursor(t *testing.T) {
2929 want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
3030 },
3131 {
32- name: "hidden pane cursor falls back to the default shape",
32+ name: "hidden pane cursor keeps its app-set shape",
3333 overlays: capturedOverlayRenderState{palette: withCaret(4)},
34- pane: cursorOut{hidden: true},
34+ pane: capturedCursorInputs{style: 6, hasStyle: true},
35+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
36+ },
37+ {
38+ name: "unstyled pane cursor falls back to the default shape",
39+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
40+ pane: capturedCursorInputs{visible: true, renderable: true},
3541 want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true},
3642 },
3743 {
@@ -15,11 +15,11 @@ func TestOverlayInputCursor(t *testing.T) {
15 return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true}15 return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true}
16 }16 }
17 notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}17 notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}
18- bar := cursorOut{valid: true, row: 5, col: 5, style: 6, hasStyle: true}18+ bar := capturedCursorInputs{style: 6, hasStyle: true, visible: true, renderable: true}
19 for _, tt := range []struct {19 for _, tt := range []struct {
20 name string20 name string
21 overlays capturedOverlayRenderState21 overlays capturedOverlayRenderState
22- pane cursorOut22+ pane capturedCursorInputs
23 want cursorOut23 want cursorOut
24 }{24 }{
25 {25 {
@@ -29,9 +29,15 @@ func TestOverlayInputCursor(t *testing.T) {
29 want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},29 want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
30 },30 },
31 {31 {
32- name: "hidden pane cursor falls back to the default shape",32+ name: "hidden pane cursor keeps its app-set shape",
33 overlays: capturedOverlayRenderState{palette: withCaret(4)},33 overlays: capturedOverlayRenderState{palette: withCaret(4)},
34- pane: cursorOut{hidden: true},34+ pane: capturedCursorInputs{style: 6, hasStyle: true},
35+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
36+ },
37+ {
38+ name: "unstyled pane cursor falls back to the default shape",
39+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
40+ pane: capturedCursorInputs{visible: true, renderable: true},
35 want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true},41 want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true},
36 },42 },
37 {43 {
M internal/usecase/daemon/render_pipeline.go +3 -3
@@ -246,7 +246,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
246246 }
247247 cursor := desiredCapturedCursor(state.cursor, contentY)
248248 if overlaysActive {
249- cursor = overlayInputCursor(state.overlays, cursor, width, frameHeight)
249+ cursor = overlayInputCursor(state.overlays, state.cursor, width, frameHeight)
250250 }
251251 // A plain copy viewport is painted on a clone or the spare viewport page,
252252 // never into baseFrame. Keep
@@ -296,7 +296,7 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
296296 // A real cursor (rather than a drawn caret) lets client predictive echo type
297297 // ahead on the input line. Any other overlay hides the cursor. The caret keeps
298298 // the focused pane's cursor shape, so opening a modal never changes it.
299-func overlayInputCursor(o capturedOverlayRenderState, pane cursorOut, width, height int) cursorOut {
299+func overlayInputCursor(o capturedOverlayRenderState, pane capturedCursorInputs, width, height int) cursorOut {
300300 for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
301301 if !modal.active {
302302 continue
@@ -306,7 +306,7 @@ func overlayInputCursor(o capturedOverlayRenderState, pane cursorOut, width, hei
306306 break
307307 }
308308 style := 1
309- if pane.valid {
309+ if pane.hasStyle {
310310 style = pane.style
311311 }
312312 return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}
@@ -246,7 +246,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
246 }246 }
247 cursor := desiredCapturedCursor(state.cursor, contentY)247 cursor := desiredCapturedCursor(state.cursor, contentY)
248 if overlaysActive {248 if overlaysActive {
249- cursor = overlayInputCursor(state.overlays, cursor, width, frameHeight)249+ cursor = overlayInputCursor(state.overlays, state.cursor, width, frameHeight)
250 }250 }
251 // A plain copy viewport is painted on a clone or the spare viewport page,251 // A plain copy viewport is painted on a clone or the spare viewport page,
252 // never into baseFrame. Keep252 // never into baseFrame. Keep
@@ -296,7 +296,7 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
296 // A real cursor (rather than a drawn caret) lets client predictive echo type296 // A real cursor (rather than a drawn caret) lets client predictive echo type
297 // ahead on the input line. Any other overlay hides the cursor. The caret keeps297 // ahead on the input line. Any other overlay hides the cursor. The caret keeps
298 // the focused pane's cursor shape, so opening a modal never changes it.298 // the focused pane's cursor shape, so opening a modal never changes it.
299-func overlayInputCursor(o capturedOverlayRenderState, pane cursorOut, width, height int) cursorOut {299+func overlayInputCursor(o capturedOverlayRenderState, pane capturedCursorInputs, width, height int) cursorOut {
300 for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {300 for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
301 if !modal.active {301 if !modal.active {
302 continue302 continue
@@ -306,7 +306,7 @@ func overlayInputCursor(o capturedOverlayRenderState, pane cursorOut, width, hei
306 break306 break
307 }307 }
308 style := 1308 style := 1
309- if pane.valid {309+ if pane.hasStyle {
310 style = pane.style310 style = pane.style
311 }311 }
312 return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}312 return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}

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