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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 558 | 558 | } | |
| 559 | 559 | width := len(p.cells) | |
| 560 | 560 | 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 | 567 | for i := col; i < width; i++ { | |
| 562 | 568 | cell, known := p.effective(i) | |
| 563 | 569 | 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 - 1 | 560 | 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) { | |||
| 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 | 422 | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | |
| 404 | 423 | t.Parallel() | |
| 405 | 424 | 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 apcFilter | 424 | var f apcFilter | ||
M internal/usecase/daemon/overlay_cursor_test.go +36 -8
| @@ -14,49 +14,77 @@ func TestOverlayInputCursor(t *testing.T) { | |||
| 14 | 14 | withCaret := func(col int) capturedModal { | |
| 15 | 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 | 19 | for _, tt := range []struct { | |
| 18 | 20 | name string | |
| 19 | 21 | overlays capturedOverlayRenderState | |
| 22 | + | pane cursorOut | |
| 20 | 23 | 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 | 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 | 38 | name: "prompt above palette owns the cursor", | |
| 29 | 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 | 56 | name: "copy search caret owns the cursor", | |
| 34 | 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 | 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 | 65 | want: cursorOut{hidden: true}, | |
| 41 | 66 | }, | |
| 42 | 67 | { | |
| 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, | |
| 45 | 71 | want: cursorOut{hidden: true}, | |
| 46 | 72 | }, | |
| 47 | 73 | { | |
| 48 | 74 | name: "caret outside the frame hides the cursor", | |
| 49 | 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 | 77 | want: cursorOut{hidden: true}, | |
| 51 | 78 | }, | |
| 52 | 79 | { | |
| 53 | 80 | name: "copy mode without a modal hides the cursor", | |
| 54 | 81 | overlays: capturedOverlayRenderState{copyActive: true}, | |
| 82 | + | pane: bar, | |
| 55 | 83 | want: cursorOut{hidden: true}, | |
| 56 | 84 | }, | |
| 57 | 85 | } { | |
| 58 | 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 | } | |
| @@ -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 string | 20 | name string | ||
| 19 | overlays capturedOverlayRenderState | 21 | overlays capturedOverlayRenderState | ||
| 22 | + | pane cursorOut | |||
| 20 | want cursorOut | 23 | 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 { | |||
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | 154 | 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 | |
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | 160 | // writePaneScreenLocked is the production VT write boundary. The first | |
| @@ -411,7 +411,7 @@ func captureLocalRenderState( | |||
| 411 | 411 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | |
| 412 | 412 | captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus | |
| 413 | 413 | if captured.focused { | |
| 414 | - | state.cursor = captureCursorInputsLocked(p, placement.Content, overlays) | |
| 414 | + | state.cursor = captureCursorInputsLocked(p, placement.Content) | |
| 415 | 415 | } | |
| 416 | 416 | p.mu.Unlock() | |
| 417 | 417 | translated := captured.rawDamage[:0] | |
| @@ -448,7 +448,7 @@ func captureLocalRenderState( | |||
| 448 | 448 | // A visible floating pane is the terminal input target, so its structural | |
| 449 | 449 | // border carries the focused semantic role independently of its content. | |
| 450 | 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 | 452 | p.mu.Unlock() | |
| 453 | 453 | } | |
| 454 | 454 | scratch.receipts = state.receipts | |
| @@ -473,10 +473,9 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |||
| 473 | 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 | 477 | 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} | |
| 480 | 479 | } | |
| 481 | 480 | ||
| 482 | 481 | // 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 int | 155 | + | row, col, style int |
| 156 | - | hasStyle, visible, renderable, hiddenByOverlay bool | 156 | + | hasStyle, visible, renderable bool |
| 157 | - | content domain.Rect | 157 | + | content domain.Rect |
| 158 | } | 158 | } | ||
| 159 | 159 | ||||
| 160 | // writePaneScreenLocked is the production VT write boundary. The first | 160 | // 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.focus | 412 | 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 structural | 448 | // 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.receipts | 454 | 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.noticesOverlayActive | 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} |
| 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 a | 481 | // 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 ... | |||
| 244 | 244 | damage = appendToastDamage(damage, in.toastFootprints) | |
| 245 | 245 | damage = appendToastDamage(damage, toastFootprints) | |
| 246 | 246 | } | |
| 247 | - | cursorInputs := state.cursor | |
| 248 | - | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | |
| 249 | - | cursor := desiredCapturedCursor(cursorInputs, contentY) | |
| 247 | + | cursor := desiredCapturedCursor(state.cursor, contentY) | |
| 250 | 248 | if overlaysActive { | |
| 251 | - | cursor = overlayInputCursor(state.overlays, width, frameHeight) | |
| 249 | + | cursor = overlayInputCursor(state.overlays, cursor, width, frameHeight) | |
| 252 | 250 | } | |
| 253 | 251 | // A plain copy viewport is painted on a clone or the spare viewport page, | |
| 254 | 252 | // never into baseFrame. Keep | |
| @@ -283,7 +281,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s | |||
| 283 | 281 | } | |
| 284 | 282 | ||
| 285 | 283 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |
| 286 | - | if !c.renderable || c.hiddenByOverlay || !c.visible { | |
| 284 | + | if !c.renderable || !c.visible { | |
| 287 | 285 | return cursorOut{hidden: true} | |
| 288 | 286 | } | |
| 289 | 287 | style := c.style | |
| @@ -296,20 +294,22 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |||
| 296 | 294 | // overlayInputCursor places the terminal cursor on the input caret of the | |
| 297 | 295 | // topmost modal, in the same priority order composeCapturedOverlays paints. | |
| 298 | 296 | // 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 { | |
| 301 | 300 | for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} { | |
| 302 | 301 | if !modal.active { | |
| 303 | 302 | continue | |
| 304 | 303 | } | |
| 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 { | |
| 309 | 306 | break | |
| 310 | 307 | } | |
| 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} | |
| 313 | 313 | } | |
| 314 | 314 | return cursorOut{hidden: true} | |
| 315 | 315 | } | |
| @@ -369,7 +369,6 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 369 | 369 | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection}) | |
| 370 | 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 | 374 | 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.cursor | 247 | + | 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. Keep | 252 | // 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.style | 287 | 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 the | 294 | // 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 type | 296 | // 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 | continue | 302 | continue | ||
| 304 | } | 303 | } | ||
| 305 | - | inner := modal.presentation.Inner | 304 | + | row, col := modal.presentation.Inner.Y, modal.presentation.Inner.X+modal.caretCol |
| 306 | - | row, col := inner.Y, inner.X+modal.caretCol | 305 | + | 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 | break | 306 | 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) { | |||
| 1031 | 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 | 1034 | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |
| 1046 | 1035 | p, _ := newBlockingPTY(t) | |
| 1047 | 1036 | 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 { | |||
| 131 | 131 | // paletteInputPrefix leads the query line on row 0 of the rendered frame. | |
| 132 | 132 | const paletteInputPrefix = "> " | |
| 133 | 133 | ||
| 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). | |
| 136 | 136 | 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()) | |
| 141 | 138 | } | |
| 142 | 139 | func (m *Model) Up() { | |
| 143 | 140 | 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. It | 134 | + | // 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 { | |||
| 52 | 52 | // promptInputPrefix leads the value line on row 0 of the rendered frame. | |
| 53 | 53 | const promptInputPrefix = "> " | |
| 54 | 54 | ||
| 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). | |
| 57 | 57 | 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()) | |
| 62 | 59 | } | |
| 63 | 60 | ||
| 64 | 61 | 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. It | 55 | + | // 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 @@ | |||
| 1 | 1 | 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 | 8 | // TextInput stores editable text as runes for terminal overlay input models. | |
| 6 | 9 | type TextInput struct { | |
| @@ -30,13 +33,14 @@ func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer | |||
| 30 | 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 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) { | |
| 37 | 41 | col := 0 | |
| 38 | 42 | for _, r := range prefix + value { | |
| 39 | 43 | col += renderer.RuneWidth(r) | |
| 40 | 44 | } | |
| 41 | - | return col, col < width | |
| 45 | + | return col, inner.Rows > 0 && col < inner.Cols | |
| 42 | 46 | } | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | package ui | 1 | 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 a | 36 | + | // 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 not | 37 | + | // 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 := 0 | 41 | 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 < width | 45 | + | return col, inner.Rows > 0 && col < inner.Cols |
| 42 | } | 46 | } | ||
M internal/usecase/ui/textinput_test.go +9 -7
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | 6 | renderer "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | 8 | "github.com/stretchr/testify/require" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -39,19 +40,20 @@ func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) { | |||
| 39 | 40 | func TestInputCaret(t *testing.T) { | |
| 40 | 41 | for _, tt := range []struct { | |
| 41 | 42 | name string | |
| 42 | - | width int | |
| 43 | + | width, rows int | |
| 43 | 44 | prefix, value string | |
| 44 | 45 | wantCol int | |
| 45 | 46 | wantOK bool | |
| 46 | 47 | }{ | |
| 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}, | |
| 52 | 54 | } { | |
| 53 | 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 | 57 | require.Equal(t, tt.wantCol, col) | |
| 56 | 58 | require.Equal(t, tt.wantOK, ok) | |
| 57 | 59 | }) | |
| @@ -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 string | 42 | name string | ||
| 42 | - | width int | 43 | + | width, rows int |
| 43 | prefix, value string | 44 | prefix, value string | ||
| 44 | wantCol int | 45 | wantCol int | ||
| 45 | wantOK bool | 46 | 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 { | |||
| 73 | 73 | // searchInputPrefix leads the query line on row 0 of the rendered frame. | |
| 74 | 74 | const searchInputPrefix = "/" | |
| 75 | 75 | ||
| 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). | |
| 78 | 78 | 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()) | |
| 83 | 80 | } | |
| 84 | 81 | ||
| 85 | 82 | 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. It | 76 | + | // 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() { | ||