detached 28cbe10

refactor(overlay): keep pane cursor shape on modal carets and guard backspace at borders

Modal carets reuse the focused pane's cursor style instead of DECSCUSR 0, so
opening the palette never flips the cursor shape. The redundant
hiddenByOverlay cursor state is removed: overlayInputCursor alone decides the
cursor while an overlay is open. ui.InputCaret owns the zero-row guard.
Predictive backspace no longer erases a border left of the input.
commit
28cbe1023a0df57b234065d48b2973a78932ed0a
author
brice · · 2026-10-07 20:32 UTC
parent
9f742a8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
11 files · +109 -72
M internal/usecase/client/predictive_echo.go +6 -0
@@ -558,6 +558,12 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
558558 }
559559 width := len(p.cells)
560560 col := p.cursor.col - 1
561+ if left, known := p.effective(col); known && isBorderRune(left.Rune) {
562+ // The input starts after a border (a modal box or pane divider):
563+ // backspace cannot erase it.
564+ p.becomeTentative()
565+ return
566+ }
561567 for i := col; i < width; i++ {
562568 cell, known := p.effective(i)
563569 if known && !isPlainCell(cell) {
@@ -558,6 +558,12 @@ func (p *echoPredictor) backspace(seq uint64, now time.Time) {
558 }558 }
559 width := len(p.cells)559 width := len(p.cells)
560 col := p.cursor.col - 1560 col := p.cursor.col - 1
561+ if left, known := p.effective(col); known && isBorderRune(left.Rune) {
562+ // The input starts after a border (a modal box or pane divider):
563+ // backspace cannot erase it.
564+ p.becomeTentative()
565+ return
566+ }
561 for i := col; i < width; i++ {567 for i := col; i < width; i++ {
562 cell, known := p.effective(i)568 cell, known := p.effective(i)
563 if known && !isPlainCell(cell) {569 if known && !isPlainCell(cell) {
M internal/usecase/client/predictive_echo_test.go +19 -0
@@ -400,6 +400,25 @@ func TestEchoPredictorTypesAheadInModalInput(t *testing.T) {
400400 }
401401 }
402402
403+// TestEchoPredictorBackspaceStopsAtBorder holds backspace past an empty
404+// modal query: the input's left border is never predicted away.
405+func TestEchoPredictorBackspaceStopsAtBorder(t *testing.T) {
406+ t.Parallel()
407+ h := newEchoHarness(t, domain.EchoPredictAlways)
408+ h.daemon("\x1b[2;1H│\x1b[2;16H│\x1b[2;2H", 0)
409+ h.warm(10 * time.Millisecond)
410+ h.typed("s")
411+ h.daemon("\x1b[2;2Hs", h.seq)
412+ h.typed("\x7f")
413+ got := string(h.typed("\x7f"))
414+ if strings.Contains(got, "\x1b[2;1H") {
415+ t.Fatalf("backspace predicted over the border: %q", got)
416+ }
417+ if h.p.cursor.active && h.p.cursor.col < 1 {
418+ t.Fatalf("predicted cursor crossed the border: col %d", h.p.cursor.col)
419+ }
420+}
421+
403422 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
404423 t.Parallel()
405424 var f apcFilter
@@ -400,6 +400,25 @@ func TestEchoPredictorTypesAheadInModalInput(t *testing.T) {
400 }400 }
401 }401 }
402 402
403+// TestEchoPredictorBackspaceStopsAtBorder holds backspace past an empty
404+// modal query: the input's left border is never predicted away.
405+func TestEchoPredictorBackspaceStopsAtBorder(t *testing.T) {
406+ t.Parallel()
407+ h := newEchoHarness(t, domain.EchoPredictAlways)
408+ h.daemon("\x1b[2;1H│\x1b[2;16H│\x1b[2;2H", 0)
409+ h.warm(10 * time.Millisecond)
410+ h.typed("s")
411+ h.daemon("\x1b[2;2Hs", h.seq)
412+ h.typed("\x7f")
413+ got := string(h.typed("\x7f"))
414+ if strings.Contains(got, "\x1b[2;1H") {
415+ t.Fatalf("backspace predicted over the border: %q", got)
416+ }
417+ if h.p.cursor.active && h.p.cursor.col < 1 {
418+ t.Fatalf("predicted cursor crossed the border: col %d", h.p.cursor.col)
419+ }
420+}
421+
403 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {422 func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) {
404 t.Parallel()423 t.Parallel()
405 var f apcFilter424 var f apcFilter
M internal/usecase/daemon/overlay_cursor_test.go +36 -8
@@ -14,49 +14,77 @@ func TestOverlayInputCursor(t *testing.T) {
1414 withCaret := func(col int) capturedModal {
1515 return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true}
1616 }
17+ notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}
18+ bar := cursorOut{valid: true, row: 5, col: 5, style: 6, hasStyle: true}
1719 for _, tt := range []struct {
1820 name string
1921 overlays capturedOverlayRenderState
22+ pane cursorOut
2023 want cursorOut
2124 }{
2225 {
23- name: "palette caret owns the cursor",
26+ name: "palette caret owns the cursor and keeps the pane shape",
2427 overlays: capturedOverlayRenderState{palette: withCaret(4)},
25- want: cursorOut{valid: true, row: 2, col: 7, hasStyle: true},
28+ pane: bar,
29+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
30+ },
31+ {
32+ name: "hidden pane cursor falls back to the default shape",
33+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
34+ pane: cursorOut{hidden: true},
35+ want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true},
2636 },
2737 {
2838 name: "prompt above palette owns the cursor",
2939 overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)},
30- want: cursorOut{valid: true, row: 2, col: 9, hasStyle: true},
40+ pane: bar,
41+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
42+ },
43+ {
44+ name: "prompt above copy search owns the cursor",
45+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), prompt: withCaret(6)},
46+ pane: bar,
47+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
48+ },
49+ {
50+ name: "palette above notices owns the cursor",
51+ overlays: capturedOverlayRenderState{palette: withCaret(4), noticesOverlay: notices},
52+ pane: bar,
53+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
3154 },
3255 {
3356 name: "copy search caret owns the cursor",
3457 overlays: capturedOverlayRenderState{copySearch: withCaret(1)},
35- want: cursorOut{valid: true, row: 2, col: 4, hasStyle: true},
58+ pane: bar,
59+ want: cursorOut{valid: true, row: 2, col: 4, style: 6, hasStyle: true},
3660 },
3761 {
3862 name: "notices above copy search hide the cursor",
39- overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}},
63+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: notices},
64+ pane: bar,
4065 want: cursorOut{hidden: true},
4166 },
4267 {
43- name: "full input line hides the cursor",
44- overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 10}},
68+ name: "modal without caret hides the cursor",
69+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 4}},
70+ pane: bar,
4571 want: cursorOut{hidden: true},
4672 },
4773 {
4874 name: "caret outside the frame hides the cursor",
4975 overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}},
76+ pane: bar,
5077 want: cursorOut{hidden: true},
5178 },
5279 {
5380 name: "copy mode without a modal hides the cursor",
5481 overlays: capturedOverlayRenderState{copyActive: true},
82+ pane: bar,
5583 want: cursorOut{hidden: true},
5684 },
5785 } {
5886 t.Run(tt.name, func(t *testing.T) {
59- require.Equal(t, tt.want, overlayInputCursor(tt.overlays, 20, 10))
87+ require.Equal(t, tt.want, overlayInputCursor(tt.overlays, tt.pane, 20, 10))
6088 })
6189 }
6290 }
@@ -14,49 +14,77 @@ func TestOverlayInputCursor(t *testing.T) {
14 withCaret := func(col int) capturedModal {14 withCaret := func(col int) capturedModal {
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}}
18+ bar := cursorOut{valid: true, row: 5, col: 5, style: 6, hasStyle: true}
17 for _, tt := range []struct {19 for _, tt := range []struct {
18 name string20 name string
19 overlays capturedOverlayRenderState21 overlays capturedOverlayRenderState
22+ pane cursorOut
20 want cursorOut23 want cursorOut
21 }{24 }{
22 {25 {
23- name: "palette caret owns the cursor",26+ name: "palette caret owns the cursor and keeps the pane shape",
24 overlays: capturedOverlayRenderState{palette: withCaret(4)},27 overlays: capturedOverlayRenderState{palette: withCaret(4)},
25- want: cursorOut{valid: true, row: 2, col: 7, hasStyle: true},28+ pane: bar,
29+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
30+ },
31+ {
32+ name: "hidden pane cursor falls back to the default shape",
33+ overlays: capturedOverlayRenderState{palette: withCaret(4)},
34+ pane: cursorOut{hidden: true},
35+ want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true},
26 },36 },
27 {37 {
28 name: "prompt above palette owns the cursor",38 name: "prompt above palette owns the cursor",
29 overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)},39 overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)},
30- want: cursorOut{valid: true, row: 2, col: 9, hasStyle: true},40+ pane: bar,
41+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
42+ },
43+ {
44+ name: "prompt above copy search owns the cursor",
45+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), prompt: withCaret(6)},
46+ pane: bar,
47+ want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true},
48+ },
49+ {
50+ name: "palette above notices owns the cursor",
51+ overlays: capturedOverlayRenderState{palette: withCaret(4), noticesOverlay: notices},
52+ pane: bar,
53+ want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true},
31 },54 },
32 {55 {
33 name: "copy search caret owns the cursor",56 name: "copy search caret owns the cursor",
34 overlays: capturedOverlayRenderState{copySearch: withCaret(1)},57 overlays: capturedOverlayRenderState{copySearch: withCaret(1)},
35- want: cursorOut{valid: true, row: 2, col: 4, hasStyle: true},58+ pane: bar,
59+ want: cursorOut{valid: true, row: 2, col: 4, style: 6, hasStyle: true},
36 },60 },
37 {61 {
38 name: "notices above copy search hide the cursor",62 name: "notices above copy search hide the cursor",
39- overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}},63+ overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: notices},
64+ pane: bar,
40 want: cursorOut{hidden: true},65 want: cursorOut{hidden: true},
41 },66 },
42 {67 {
43- name: "full input line hides the cursor",68+ name: "modal without caret hides the cursor",
44- overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 10}},69+ overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 4}},
70+ pane: bar,
45 want: cursorOut{hidden: true},71 want: cursorOut{hidden: true},
46 },72 },
47 {73 {
48 name: "caret outside the frame hides the cursor",74 name: "caret outside the frame hides the cursor",
49 overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}},75 overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}},
76+ pane: bar,
50 want: cursorOut{hidden: true},77 want: cursorOut{hidden: true},
51 },78 },
52 {79 {
53 name: "copy mode without a modal hides the cursor",80 name: "copy mode without a modal hides the cursor",
54 overlays: capturedOverlayRenderState{copyActive: true},81 overlays: capturedOverlayRenderState{copyActive: true},
82+ pane: bar,
55 want: cursorOut{hidden: true},83 want: cursorOut{hidden: true},
56 },84 },
57 } {85 } {
58 t.Run(tt.name, func(t *testing.T) {86 t.Run(tt.name, func(t *testing.T) {
59- require.Equal(t, tt.want, overlayInputCursor(tt.overlays, 20, 10))87+ require.Equal(t, tt.want, overlayInputCursor(tt.overlays, tt.pane, 20, 10))
60 })88 })
61 }89 }
62 }90 }
M internal/usecase/daemon/render_capture.go +7 -8
@@ -152,9 +152,9 @@ func (o capturedOverlayRenderState) active() bool {
152152 }
153153
154154 type capturedCursorInputs struct {
155- row, col, style int
156- hasStyle, visible, renderable, hiddenByOverlay bool
157- content domain.Rect
155+ row, col, style int
156+ hasStyle, visible, renderable bool
157+ content domain.Rect
158158 }
159159
160160 // writePaneScreenLocked is the production VT write boundary. The first
@@ -411,7 +411,7 @@ func captureLocalRenderState(
411411 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
412412 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
413413 if captured.focused {
414- state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
414+ state.cursor = captureCursorInputsLocked(p, placement.Content)
415415 }
416416 p.mu.Unlock()
417417 translated := captured.rawDamage[:0]
@@ -448,7 +448,7 @@ func captureLocalRenderState(
448448 // A visible floating pane is the terminal input target, so its structural
449449 // border carries the focused semantic role independently of its content.
450450 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
451- state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
451+ state.cursor = captureCursorInputsLocked(p, geometry.Inner)
452452 p.mu.Unlock()
453453 }
454454 scratch.receipts = state.receipts
@@ -473,10 +473,9 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
473473 return append(dst[:0], src...)
474474 }
475475
476-func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
476+func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs {
477477 style, hasStyle := p.screen.CursorStyle()
478- hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
479- return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
478+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, content: content}
480479 }
481480
482481 // appendStackPaneIDs returns exactly the panes whose solved placement can own a
@@ -152,9 +152,9 @@ func (o capturedOverlayRenderState) active() bool {
152 }152 }
153 153
154 type capturedCursorInputs struct {154 type capturedCursorInputs struct {
155- row, col, style int155+ row, col, style int
156- hasStyle, visible, renderable, hiddenByOverlay bool156+ hasStyle, visible, renderable bool
157- content domain.Rect157+ content domain.Rect
158 }158 }
159 159
160 // writePaneScreenLocked is the production VT write boundary. The first160 // writePaneScreenLocked is the production VT write boundary. The first
@@ -411,7 +411,7 @@ func captureLocalRenderState(
411 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})411 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
412 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus412 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
413 if captured.focused {413 if captured.focused {
414- state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)414+ state.cursor = captureCursorInputsLocked(p, placement.Content)
415 }415 }
416 p.mu.Unlock()416 p.mu.Unlock()
417 translated := captured.rawDamage[:0]417 translated := captured.rawDamage[:0]
@@ -448,7 +448,7 @@ func captureLocalRenderState(
448 // A visible floating pane is the terminal input target, so its structural448 // A visible floating pane is the terminal input target, so its structural
449 // border carries the focused semantic role independently of its content.449 // border carries the focused semantic role independently of its content.
450 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}450 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
451- state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)451+ state.cursor = captureCursorInputsLocked(p, geometry.Inner)
452 p.mu.Unlock()452 p.mu.Unlock()
453 }453 }
454 scratch.receipts = state.receipts454 scratch.receipts = state.receipts
@@ -473,10 +473,9 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
473 return append(dst[:0], src...)473 return append(dst[:0], src...)
474 }474 }
475 475
476-func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {476+func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs {
477 style, hasStyle := p.screen.CursorStyle()477 style, hasStyle := p.screen.CursorStyle()
478- hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive478+ return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, content: content}
479- return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content}
480 }479 }
481 480
482 // appendStackPaneIDs returns exactly the panes whose solved placement can own a481 // appendStackPaneIDs returns exactly the panes whose solved placement can own a
M internal/usecase/daemon/render_pipeline.go +13 -14
@@ -244,11 +244,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
244244 damage = appendToastDamage(damage, in.toastFootprints)
245245 damage = appendToastDamage(damage, toastFootprints)
246246 }
247- cursorInputs := state.cursor
248- cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
249- cursor := desiredCapturedCursor(cursorInputs, contentY)
247+ cursor := desiredCapturedCursor(state.cursor, contentY)
250248 if overlaysActive {
251- cursor = overlayInputCursor(state.overlays, width, frameHeight)
249+ cursor = overlayInputCursor(state.overlays, cursor, width, frameHeight)
252250 }
253251 // A plain copy viewport is painted on a clone or the spare viewport page,
254252 // never into baseFrame. Keep
@@ -283,7 +281,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s
283281 }
284282
285283 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
286- if !c.renderable || c.hiddenByOverlay || !c.visible {
284+ if !c.renderable || !c.visible {
287285 return cursorOut{hidden: true}
288286 }
289287 style := c.style
@@ -296,20 +294,22 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
296294 // overlayInputCursor places the terminal cursor on the input caret of the
297295 // topmost modal, in the same priority order composeCapturedOverlays paints.
298296 // A real cursor (rather than a drawn caret) lets client predictive echo type
299-// ahead on the input line. Any other overlay hides the cursor.
300-func overlayInputCursor(o capturedOverlayRenderState, width, height int) cursorOut {
297+// 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.
299+func overlayInputCursor(o capturedOverlayRenderState, pane cursorOut, width, height int) cursorOut {
301300 for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
302301 if !modal.active {
303302 continue
304303 }
305- inner := modal.presentation.Inner
306- row, col := inner.Y, inner.X+modal.caretCol
307- if !modal.hasCaret || modal.caretCol >= inner.Width || inner.Height <= 0 ||
308- row < 0 || row >= height || col < 0 || col >= width {
304+ row, col := modal.presentation.Inner.Y, modal.presentation.Inner.X+modal.caretCol
305+ if !modal.hasCaret || row < 0 || row >= height || col < 0 || col >= width {
309306 break
310307 }
311- // Style 0 restores the user's terminal default cursor shape.
312- return cursorOut{valid: true, row: row, col: col, style: 0, hasStyle: true}
308+ style := 1
309+ if pane.valid {
310+ style = pane.style
311+ }
312+ return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}
313313 }
314314 return cursorOut{hidden: true}
315315 }
@@ -369,7 +369,6 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
369369 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
370370 o.prompt.caretCol, o.prompt.hasCaret = snap.promptModel.Caret(rectSize(presentation.Inner))
371371 }
372- state.cursor.hiddenByOverlay = o.active()
373372 }
374373
375374 func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
@@ -244,11 +244,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
244 damage = appendToastDamage(damage, in.toastFootprints)244 damage = appendToastDamage(damage, in.toastFootprints)
245 damage = appendToastDamage(damage, toastFootprints)245 damage = appendToastDamage(damage, toastFootprints)
246 }246 }
247- cursorInputs := state.cursor247+ cursor := desiredCapturedCursor(state.cursor, contentY)
248- cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
249- cursor := desiredCapturedCursor(cursorInputs, contentY)
250 if overlaysActive {248 if overlaysActive {
251- cursor = overlayInputCursor(state.overlays, width, frameHeight)249+ cursor = overlayInputCursor(state.overlays, cursor, width, frameHeight)
252 }250 }
253 // 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,
254 // never into baseFrame. Keep252 // never into baseFrame. Keep
@@ -283,7 +281,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s
283 }281 }
284 282
285 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {283 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
286- if !c.renderable || c.hiddenByOverlay || !c.visible {284+ if !c.renderable || !c.visible {
287 return cursorOut{hidden: true}285 return cursorOut{hidden: true}
288 }286 }
289 style := c.style287 style := c.style
@@ -296,20 +294,22 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
296 // overlayInputCursor places the terminal cursor on the input caret of the294 // overlayInputCursor places the terminal cursor on the input caret of the
297 // topmost modal, in the same priority order composeCapturedOverlays paints.295 // topmost modal, in the same priority order composeCapturedOverlays paints.
298 // 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
299-// ahead on the input line. Any other overlay hides the cursor.297+// ahead on the input line. Any other overlay hides the cursor. The caret keeps
300-func overlayInputCursor(o capturedOverlayRenderState, width, height int) cursorOut {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 {
301 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} {
302 if !modal.active {301 if !modal.active {
303 continue302 continue
304 }303 }
305- inner := modal.presentation.Inner304+ row, col := modal.presentation.Inner.Y, modal.presentation.Inner.X+modal.caretCol
306- row, col := inner.Y, inner.X+modal.caretCol305+ if !modal.hasCaret || row < 0 || row >= height || col < 0 || col >= width {
307- if !modal.hasCaret || modal.caretCol >= inner.Width || inner.Height <= 0 ||
308- row < 0 || row >= height || col < 0 || col >= width {
309 break306 break
310 }307 }
311- // Style 0 restores the user's terminal default cursor shape.308+ style := 1
312- return cursorOut{valid: true, row: row, col: col, style: 0, hasStyle: true}309+ if pane.valid {
310+ style = pane.style
311+ }
312+ return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}
313 }313 }
314 return cursorOut{hidden: true}314 return cursorOut{hidden: true}
315 }315 }
@@ -369,7 +369,6 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
369 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})369 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
370 o.prompt.caretCol, o.prompt.hasCaret = snap.promptModel.Caret(rectSize(presentation.Inner))370 o.prompt.caretCol, o.prompt.hasCaret = snap.promptModel.Caret(rectSize(presentation.Inner))
371 }371 }
372- state.cursor.hiddenByOverlay = o.active()
373 }372 }
374 373
375 func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {374 func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
M internal/usecase/daemon/render_test.go +0 -11
@@ -1031,17 +1031,6 @@ func TestPTYKittyAnimationFallbackKeepsProtocolErrorsOutOfShell(t *testing.T) {
10311031 require.Equal(t, uint64(1), snapshot.Usage().Placements, "the supported first frame remains as a static fallback")
10321032 }
10331033
1034-func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
1035- _, sess, _, _ := newManualSessionWithPTYs(t, nil)
1036- p := sess.tabs[0].focusedPane()
1037-
1038- p.mu.Lock()
1039- cursor := captureCursorInputsLocked(p, domain.Rect{Width: 80, Height: 23}, capturedOverlayRenderState{noticesOverlayActive: true})
1040- p.mu.Unlock()
1041-
1042- require.True(t, cursor.hiddenByOverlay)
1043-}
1044-
10451034 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
10461035 p, _ := newBlockingPTY(t)
10471036 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -1031,17 +1031,6 @@ func TestPTYKittyAnimationFallbackKeepsProtocolErrorsOutOfShell(t *testing.T) {
1031 require.Equal(t, uint64(1), snapshot.Usage().Placements, "the supported first frame remains as a static fallback")1031 require.Equal(t, uint64(1), snapshot.Usage().Placements, "the supported first frame remains as a static fallback")
1032 }1032 }
1033 1033
1034-func TestCaptureCursorInputsHidesFocusedCursorForNoticesOverlay(t *testing.T) {
1035- _, sess, _, _ := newManualSessionWithPTYs(t, nil)
1036- p := sess.tabs[0].focusedPane()
1037-
1038- p.mu.Lock()
1039- cursor := captureCursorInputsLocked(p, domain.Rect{Width: 80, Height: 23}, capturedOverlayRenderState{noticesOverlayActive: true})
1040- p.mu.Unlock()
1041-
1042- require.True(t, cursor.hiddenByOverlay)
1043-}
1044-
1045 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {1034 func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) {
1046 p, _ := newBlockingPTY(t)1035 p, _ := newBlockingPTY(t)
1047 d, sess, ac, sends := newManualSessionWithPTYs(t, p)1036 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
M internal/usecase/palette/model.go +3 -6
@@ -131,13 +131,10 @@ func (m *Model) Query() string {
131131 // paletteInputPrefix leads the query line on row 0 of the rendered frame.
132132 const paletteInputPrefix = "> "
133133
134-// Caret is the query caret column on row 0 of a frame inner.Cols wide. It
135-// reports false when the query fills the line.
134+// Caret is the query caret column on row 0 of a Render frame of size inner
135+// (see ui.InputCaret).
136136 func (m *Model) Caret(inner domain.Size) (int, bool) {
137- if inner.Rows <= 0 {
138- return 0, false
139- }
140- return ui.InputCaret(inner.Cols, paletteInputPrefix, m.Query())
137+ return ui.InputCaret(inner, paletteInputPrefix, m.Query())
141138 }
142139 func (m *Model) Up() {
143140 if m == nil || len(m.matches) == 0 {
@@ -131,13 +131,10 @@ func (m *Model) Query() string {
131 // paletteInputPrefix leads the query line on row 0 of the rendered frame.131 // paletteInputPrefix leads the query line on row 0 of the rendered frame.
132 const paletteInputPrefix = "> "132 const paletteInputPrefix = "> "
133 133
134-// Caret is the query caret column on row 0 of a frame inner.Cols wide. It134+// Caret is the query caret column on row 0 of a Render frame of size inner
135-// reports false when the query fills the line.135+// (see ui.InputCaret).
136 func (m *Model) Caret(inner domain.Size) (int, bool) {136 func (m *Model) Caret(inner domain.Size) (int, bool) {
137- if inner.Rows <= 0 {137+ return ui.InputCaret(inner, paletteInputPrefix, m.Query())
138- return 0, false
139- }
140- return ui.InputCaret(inner.Cols, paletteInputPrefix, m.Query())
141 }138 }
142 func (m *Model) Up() {139 func (m *Model) Up() {
143 if m == nil || len(m.matches) == 0 {140 if m == nil || len(m.matches) == 0 {
M internal/usecase/prompt/model.go +3 -6
@@ -52,13 +52,10 @@ func (m *Model) Value() string {
5252 // promptInputPrefix leads the value line on row 0 of the rendered frame.
5353 const promptInputPrefix = "> "
5454
55-// Caret is the value caret column on row 0 of a frame inner.Cols wide. It
56-// reports false when the value fills the line.
55+// Caret is the value caret column on row 0 of a Render frame of size inner
56+// (see ui.InputCaret).
5757 func (m *Model) Caret(inner domain.Size) (int, bool) {
58- if inner.Rows <= 0 {
59- return 0, false
60- }
61- return ui.InputCaret(inner.Cols, promptInputPrefix, m.Value())
58+ return ui.InputCaret(inner, promptInputPrefix, m.Value())
6259 }
6360
6461 func (m *Model) Title() string {
@@ -52,13 +52,10 @@ func (m *Model) Value() string {
52 // promptInputPrefix leads the value line on row 0 of the rendered frame.52 // promptInputPrefix leads the value line on row 0 of the rendered frame.
53 const promptInputPrefix = "> "53 const promptInputPrefix = "> "
54 54
55-// Caret is the value caret column on row 0 of a frame inner.Cols wide. It55+// Caret is the value caret column on row 0 of a Render frame of size inner
56-// reports false when the value fills the line.56+// (see ui.InputCaret).
57 func (m *Model) Caret(inner domain.Size) (int, bool) {57 func (m *Model) Caret(inner domain.Size) (int, bool) {
58- if inner.Rows <= 0 {58+ return ui.InputCaret(inner, promptInputPrefix, m.Value())
59- return 0, false
60- }
61- return ui.InputCaret(inner.Cols, promptInputPrefix, m.Value())
62 }59 }
63 60
64 func (m *Model) Title() string {61 func (m *Model) Title() string {
M internal/usecase/ui/textinput.go +10 -6
@@ -1,6 +1,9 @@
11 package ui
22
3-import renderer "github.com/bnema/vev-vt"
3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+)
47
58 // TextInput stores editable text as runes for terminal overlay input models.
69 type TextInput struct {
@@ -30,13 +33,14 @@ func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer
3033 DrawText(f, 0, y, f.Width, prefix+value, style)
3134 }
3235
33-// InputCaret is the caret column of an input line drawn by DrawInputLine in a
34-// frame width columns wide. It reports false when prefix + value does not
35-// leave room for the caret, so a clipped line never shows a misplaced cursor.
36-func InputCaret(width int, prefix, value string) (int, bool) {
36+// InputCaret is the caret column of an input line drawn by DrawInputLine on
37+// row 0 of a frame of size inner. It reports false when the frame has no row
38+// or prefix + value leaves no room for the caret, so a clipped line never
39+// shows a misplaced cursor.
40+func InputCaret(inner domain.Size, prefix, value string) (int, bool) {
3741 col := 0
3842 for _, r := range prefix + value {
3943 col += renderer.RuneWidth(r)
4044 }
41- return col, col < width
45+ return col, inner.Rows > 0 && col < inner.Cols
4246 }
@@ -1,6 +1,9 @@
1 package ui1 package ui
2 2
3-import renderer "github.com/bnema/vev-vt"3+import (
4+ renderer "github.com/bnema/vev-vt"
5+ "github.com/bnema/vev/internal/domain"
6+)
4 7
5 // TextInput stores editable text as runes for terminal overlay input models.8 // TextInput stores editable text as runes for terminal overlay input models.
6 type TextInput struct {9 type TextInput struct {
@@ -30,13 +33,14 @@ func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer
30 DrawText(f, 0, y, f.Width, prefix+value, style)33 DrawText(f, 0, y, f.Width, prefix+value, style)
31 }34 }
32 35
33-// InputCaret is the caret column of an input line drawn by DrawInputLine in a36+// InputCaret is the caret column of an input line drawn by DrawInputLine on
34-// frame width columns wide. It reports false when prefix + value does not37+// row 0 of a frame of size inner. It reports false when the frame has no row
35-// leave room for the caret, so a clipped line never shows a misplaced cursor.38+// or prefix + value leaves no room for the caret, so a clipped line never
36-func InputCaret(width int, prefix, value string) (int, bool) {39+// shows a misplaced cursor.
40+func InputCaret(inner domain.Size, prefix, value string) (int, bool) {
37 col := 041 col := 0
38 for _, r := range prefix + value {42 for _, r := range prefix + value {
39 col += renderer.RuneWidth(r)43 col += renderer.RuneWidth(r)
40 }44 }
41- return col, col < width45+ return col, inner.Rows > 0 && col < inner.Cols
42 }46 }
M internal/usecase/ui/textinput_test.go +9 -7
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
78 "github.com/stretchr/testify/require"
89 )
910
@@ -39,19 +40,20 @@ func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) {
3940 func TestInputCaret(t *testing.T) {
4041 for _, tt := range []struct {
4142 name string
42- width int
43+ width, rows int
4344 prefix, value string
4445 wantCol int
4546 wantOK bool
4647 }{
47- {name: "after value", width: 6, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},
48- {name: "empty value", width: 6, prefix: "/", wantCol: 1, wantOK: true},
49- {name: "wide runes count two columns", width: 8, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},
50- {name: "value fills the line", width: 5, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},
51- {name: "value clipped", width: 4, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},
48+ {name: "after value", width: 6, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},
49+ {name: "empty value", width: 6, rows: 1, prefix: "/", wantCol: 1, wantOK: true},
50+ {name: "wide runes count two columns", width: 8, rows: 1, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},
51+ {name: "value fills the line", width: 5, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},
52+ {name: "value clipped", width: 4, rows: 1, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},
53+ {name: "no row", width: 6, rows: 0, prefix: "> ", value: "a", wantCol: 3, wantOK: false},
5254 } {
5355 t.Run(tt.name, func(t *testing.T) {
54- col, ok := InputCaret(tt.width, tt.prefix, tt.value)
56+ col, ok := InputCaret(domain.Size{Cols: tt.width, Rows: tt.rows}, tt.prefix, tt.value)
5557 require.Equal(t, tt.wantCol, col)
5658 require.Equal(t, tt.wantOK, ok)
5759 })
@@ -4,6 +4,7 @@ import (
4 "testing"4 "testing"
5 5
6 renderer "github.com/bnema/vev-vt"6 renderer "github.com/bnema/vev-vt"
7+ "github.com/bnema/vev/internal/domain"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
@@ -39,19 +40,20 @@ func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) {
39 func TestInputCaret(t *testing.T) {40 func TestInputCaret(t *testing.T) {
40 for _, tt := range []struct {41 for _, tt := range []struct {
41 name string42 name string
42- width int43+ width, rows int
43 prefix, value string44 prefix, value string
44 wantCol int45 wantCol int
45 wantOK bool46 wantOK bool
46 }{47 }{
47- {name: "after value", width: 6, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},48+ {name: "after value", width: 6, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: true},
48- {name: "empty value", width: 6, prefix: "/", wantCol: 1, wantOK: true},49+ {name: "empty value", width: 6, rows: 1, prefix: "/", wantCol: 1, wantOK: true},
49- {name: "wide runes count two columns", width: 8, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},50+ {name: "wide runes count two columns", width: 8, rows: 1, prefix: "> ", value: "日本", wantCol: 6, wantOK: true},
50- {name: "value fills the line", width: 5, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},51+ {name: "value fills the line", width: 5, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: false},
51- {name: "value clipped", width: 4, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},52+ {name: "value clipped", width: 4, rows: 1, prefix: "> ", value: "abcdef", wantCol: 8, wantOK: false},
53+ {name: "no row", width: 6, rows: 0, prefix: "> ", value: "a", wantCol: 3, wantOK: false},
52 } {54 } {
53 t.Run(tt.name, func(t *testing.T) {55 t.Run(tt.name, func(t *testing.T) {
54- col, ok := InputCaret(tt.width, tt.prefix, tt.value)56+ col, ok := InputCaret(domain.Size{Cols: tt.width, Rows: tt.rows}, tt.prefix, tt.value)
55 require.Equal(t, tt.wantCol, col)57 require.Equal(t, tt.wantCol, col)
56 require.Equal(t, tt.wantOK, ok)58 require.Equal(t, tt.wantOK, ok)
57 })59 })
M internal/usecase/visualsearch/model.go +3 -6
@@ -73,13 +73,10 @@ func (m *Model) Query() string {
7373 // searchInputPrefix leads the query line on row 0 of the rendered frame.
7474 const searchInputPrefix = "/"
7575
76-// Caret is the query caret column on row 0 of a frame inner.Cols wide. It
77-// reports false when the query fills the line.
76+// Caret is the query caret column on row 0 of a Render frame of size inner
77+// (see ui.InputCaret).
7878 func (m *Model) Caret(inner domain.Size) (int, bool) {
79- if inner.Rows <= 0 {
80- return 0, false
81- }
82- return ui.InputCaret(inner.Cols, searchInputPrefix, m.Query())
79+ return ui.InputCaret(inner, searchInputPrefix, m.Query())
8380 }
8481
8582 func (m *Model) Up() {
@@ -73,13 +73,10 @@ func (m *Model) Query() string {
73 // searchInputPrefix leads the query line on row 0 of the rendered frame.73 // searchInputPrefix leads the query line on row 0 of the rendered frame.
74 const searchInputPrefix = "/"74 const searchInputPrefix = "/"
75 75
76-// Caret is the query caret column on row 0 of a frame inner.Cols wide. It76+// Caret is the query caret column on row 0 of a Render frame of size inner
77-// reports false when the query fills the line.77+// (see ui.InputCaret).
78 func (m *Model) Caret(inner domain.Size) (int, bool) {78 func (m *Model) Caret(inner domain.Size) (int, bool) {
79- if inner.Rows <= 0 {79+ return ui.InputCaret(inner, searchInputPrefix, m.Query())
80- return 0, false
81- }
82- return ui.InputCaret(inner.Cols, searchInputPrefix, m.Query())
83 }80 }
84 81
85 func (m *Model) Up() {82 func (m *Model) Up() {

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