feat(overlay): predictive echo in palette, prompt and copy search (#322)
* feat(overlay): let modal input lines own the terminal cursor for predictive echo Palette, prompt and copy-search inputs no longer draw an inverse caret cell. Each model reports its caret column, and the topmost modal with a caret places the real terminal cursor there, so remote predictive echo types ahead in them. * 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. * fix(overlay): keep the app-set cursor shape on modal carets when the pane cursor is hidden
- commit
- 4c16dd8299ca2e7c99d7f7eafad1427a88af0ecd
- author
- bnema · · 2026-10-07 20:41 UTC
- committer
- GitHub · · 2026-10-07 20:41 UTC
- parent
- 76d7fa2
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown - refs
- tag: v0.7.0
16 files · +297 -59
M docs/architecture.md +1 -1
| @@ -93,7 +93,7 @@ Attention remains a daemon fact. The broker publishes it from fresh observations | |||
| 93 | 93 | ||
| 94 | 94 | ### Terminal focus | |
| 95 | 95 | ||
| 96 | - | On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Overlays, resizes, and a hidden cursor drop every guess. `echo.predict` selects `adaptive`, `always`, or `never`. | |
| 96 | + | On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Client-owned overlays, resizes, and a hidden cursor drop every guess. Daemon modals with an input line (command palette, prompt, copy search) place the real terminal cursor on their caret instead of drawing one, so typing in them is predicted like a shell line. `echo.predict` selects `adaptive`, `always`, or `never`. | |
| 97 | 97 | ||
| 98 | 98 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | |
| 99 | 99 | ||
| @@ -93,7 +93,7 @@ Attention remains a daemon fact. The broker publishes it from fresh observations | |||||
| 93 | 93 | ||||
| 94 | ### Terminal focus | 94 | ### Terminal focus | ||
| 95 | 95 | ||||
| 96 | - | On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Overlays, resizes, and a hidden cursor drop every guess. `echo.predict` selects `adaptive`, `always`, or `never`. | 96 | + | On remote attachments the client predicts local echo, after mosh. The attached loop mirrors every applied `Output` in a local `vev-vt` screen, guesses printable keys and backspace on the cursor row, and draws the guesses through the foreground's output lease. `Output.Echo` acknowledges input once its echo reached the screen, so the client checks each guess against the mirror and erases a wrong one. A smoothed round trip from those acknowledgements decides when guesses show and when they are underlined. Input the client cannot model makes later guesses tentative until a correct guess confirms them. Client-owned overlays, resizes, and a hidden cursor drop every guess. Daemon modals with an input line (command palette, prompt, copy search) place the real terminal cursor on their caret instead of drawing one, so typing in them is predicted like a shell line. `echo.predict` selects `adaptive`, `always`, or `never`. |
| 97 | 97 | ||||
| 98 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | 98 | The client turns on focus reporting (DEC mode 1004). Its terminal input pump strips each `ESC [ I`/`ESC [ O` report into a `TerminalFocusState` that any client feature can read or watch, `Hello` declares the focus known at attach, so the first paint already respects it, and the attached loop sends every later change to the daemon as `TerminalFocus` ahead of the input read that carried it. The daemon keeps it per attachment as a lock-free `terminalFocus()`. Unknown focus, from a terminal that never reports, counts as possibly seen, so every feature keeps its previous behavior there. Attention uses it: only an attachment that may be seen clears a bell. Geometry uses it too: a reported focus gain makes the attachment the latest shared PTY geometry claimant, so session content resizes to the focused window. | ||
| 99 | 99 | ||||
M docs/remote-resilience.md +1 -1
| @@ -34,7 +34,7 @@ The command palette is rendered by the remote daemon, so opening it needs a work | |||
| 34 | 34 | ||
| 35 | 35 | ## Typing on a slow link | |
| 36 | 36 | ||
| 37 | - | On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md). | |
| 37 | + | On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line, including in the command palette, rename prompts, and copy search. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md). | |
| 38 | 38 | ||
| 39 | 39 | ## Connection states | |
| 40 | 40 | ||
| @@ -34,7 +34,7 @@ The command palette is rendered by the remote daemon, so opening it needs a work | |||||
| 34 | 34 | ||||
| 35 | ## Typing on a slow link | 35 | ## Typing on a slow link | ||
| 36 | 36 | ||||
| 37 | - | On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md). | 37 | + | On remote sessions vev shows what you type before the server answers, like mosh. When the round trip exceeds about 60 ms, typed characters and backspace appear at once on the cursor line, including in the command palette, rename prompts, and copy search. Above about 160 ms they are underlined until the server confirms them. A wrong guess, such as a password prompt that echoes nothing, is erased when the server answers. Set `echo.predict = always` or `never` to change this; see [configuration](configuration.md). |
| 38 | 38 | ||||
| 39 | ## Connection states | 39 | ## Connection states | ||
| 40 | 40 | ||||
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 +41 -0
| @@ -378,6 +378,47 @@ func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) { | |||
| 378 | 378 | } | |
| 379 | 379 | } | |
| 380 | 380 | ||
| 381 | + | // TestEchoPredictorTypesAheadInModalInput covers daemon modals (command | |
| 382 | + | // palette, prompt, copy search): the daemon places the real cursor on their | |
| 383 | + | // input line inside a bordered box, and guesses stay inside the box. | |
| 384 | + | func TestEchoPredictorTypesAheadInModalInput(t *testing.T) { | |
| 385 | + | t.Parallel() | |
| 386 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |
| 387 | + | // Row 2: "│> " then the cursor, with the box's right border at column 15. | |
| 388 | + | h.daemon("\x1b[2;1H│> \x1b[2;16H│\x1b[2;4H", 0) | |
| 389 | + | h.warm(10 * time.Millisecond) | |
| 390 | + | if got := h.typed("s"); len(got) != 0 { | |
| 391 | + | t.Fatalf("first guess is tentative, rendered %q", got) | |
| 392 | + | } | |
| 393 | + | h.daemon("\x1b[2;4Hs", h.seq) | |
| 394 | + | got := string(h.typed("p")) | |
| 395 | + | if want := "\x1b[2;5H\x1b[0mp\x1b[0m\x1b[2;6H"; got != want { | |
| 396 | + | t.Fatalf("render = %q, want %q", got, want) | |
| 397 | + | } | |
| 398 | + | if cell := h.p.screen.Cell(15, 1); cell.Rune != '│' { | |
| 399 | + | t.Fatalf("box border moved: %q", cell.Rune) | |
| 400 | + | } | |
| 401 | + | } | |
| 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 | + | ||
| 381 | 422 | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | |
| 382 | 423 | t.Parallel() | |
| 383 | 424 | var f apcFilter | |
| @@ -378,6 +378,47 @@ func TestEchoPredictorSameSizeResizeErasesGuesses(t *testing.T) { | |||||
| 378 | } | 378 | } | ||
| 379 | } | 379 | } | ||
| 380 | 380 | ||||
| 381 | + | // TestEchoPredictorTypesAheadInModalInput covers daemon modals (command | |||
| 382 | + | // palette, prompt, copy search): the daemon places the real cursor on their | |||
| 383 | + | // input line inside a bordered box, and guesses stay inside the box. | |||
| 384 | + | func TestEchoPredictorTypesAheadInModalInput(t *testing.T) { | |||
| 385 | + | t.Parallel() | |||
| 386 | + | h := newEchoHarness(t, domain.EchoPredictAlways) | |||
| 387 | + | // Row 2: "│> " then the cursor, with the box's right border at column 15. | |||
| 388 | + | h.daemon("\x1b[2;1H│> \x1b[2;16H│\x1b[2;4H", 0) | |||
| 389 | + | h.warm(10 * time.Millisecond) | |||
| 390 | + | if got := h.typed("s"); len(got) != 0 { | |||
| 391 | + | t.Fatalf("first guess is tentative, rendered %q", got) | |||
| 392 | + | } | |||
| 393 | + | h.daemon("\x1b[2;4Hs", h.seq) | |||
| 394 | + | got := string(h.typed("p")) | |||
| 395 | + | if want := "\x1b[2;5H\x1b[0mp\x1b[0m\x1b[2;6H"; got != want { | |||
| 396 | + | t.Fatalf("render = %q, want %q", got, want) | |||
| 397 | + | } | |||
| 398 | + | if cell := h.p.screen.Cell(15, 1); cell.Rune != '│' { | |||
| 399 | + | t.Fatalf("box border moved: %q", cell.Rune) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 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 | + | ||||
| 381 | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | 422 | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | ||
| 382 | t.Parallel() | 423 | t.Parallel() | ||
| 383 | var f apcFilter | 424 | var f apcFilter | ||
A internal/usecase/daemon/overlay_cursor_test.go +121 -0
| @@ -0,0 +1,121 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | vt "github.com/bnema/vev-vt" | |
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestOverlayInputCursor(t *testing.T) { | |
| 13 | + | inner := domain.Rect{X: 3, Y: 2, Width: 10, Height: 4} | |
| 14 | + | withCaret := func(col int) capturedModal { | |
| 15 | + | return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true} | |
| 16 | + | } | |
| 17 | + | notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}} | |
| 18 | + | bar := capturedCursorInputs{style: 6, hasStyle: true, visible: true, renderable: true} | |
| 19 | + | for _, tt := range []struct { | |
| 20 | + | name string | |
| 21 | + | overlays capturedOverlayRenderState | |
| 22 | + | pane capturedCursorInputs | |
| 23 | + | want cursorOut | |
| 24 | + | }{ | |
| 25 | + | { | |
| 26 | + | name: "palette caret owns the cursor and keeps the pane shape", | |
| 27 | + | overlays: capturedOverlayRenderState{palette: withCaret(4)}, | |
| 28 | + | pane: bar, | |
| 29 | + | want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true}, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | name: "hidden pane cursor keeps its app-set shape", | |
| 33 | + | overlays: capturedOverlayRenderState{palette: withCaret(4)}, | |
| 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}, | |
| 41 | + | want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "prompt above palette owns the cursor", | |
| 45 | + | overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)}, | |
| 46 | + | pane: bar, | |
| 47 | + | want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true}, | |
| 48 | + | }, | |
| 49 | + | { | |
| 50 | + | name: "prompt above copy search owns the cursor", | |
| 51 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1), prompt: withCaret(6)}, | |
| 52 | + | pane: bar, | |
| 53 | + | want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true}, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | name: "palette above notices owns the cursor", | |
| 57 | + | overlays: capturedOverlayRenderState{palette: withCaret(4), noticesOverlay: notices}, | |
| 58 | + | pane: bar, | |
| 59 | + | want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true}, | |
| 60 | + | }, | |
| 61 | + | { | |
| 62 | + | name: "copy search caret owns the cursor", | |
| 63 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1)}, | |
| 64 | + | pane: bar, | |
| 65 | + | want: cursorOut{valid: true, row: 2, col: 4, style: 6, hasStyle: true}, | |
| 66 | + | }, | |
| 67 | + | { | |
| 68 | + | name: "notices above copy search hide the cursor", | |
| 69 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: notices}, | |
| 70 | + | pane: bar, | |
| 71 | + | want: cursorOut{hidden: true}, | |
| 72 | + | }, | |
| 73 | + | { | |
| 74 | + | name: "modal without caret hides the cursor", | |
| 75 | + | overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 4}}, | |
| 76 | + | pane: bar, | |
| 77 | + | want: cursorOut{hidden: true}, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | name: "caret outside the frame hides the cursor", | |
| 81 | + | overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}}, | |
| 82 | + | pane: bar, | |
| 83 | + | want: cursorOut{hidden: true}, | |
| 84 | + | }, | |
| 85 | + | { | |
| 86 | + | name: "copy mode without a modal hides the cursor", | |
| 87 | + | overlays: capturedOverlayRenderState{copyActive: true}, | |
| 88 | + | pane: bar, | |
| 89 | + | want: cursorOut{hidden: true}, | |
| 90 | + | }, | |
| 91 | + | } { | |
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |
| 93 | + | require.Equal(t, tt.want, overlayInputCursor(tt.overlays, tt.pane, 20, 10)) | |
| 94 | + | }) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // TestPaletteInputLineOwnsTerminalCursor drives the production render path: | |
| 99 | + | // the open palette shows the real terminal cursor after its query, which is | |
| 100 | + | // what lets client predictive echo type ahead in it. | |
| 101 | + | func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) { | |
| 102 | + | p, release := newBlockingPTY(t) | |
| 103 | + | defer release() | |
| 104 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 105 | + | client := vt.NewScreen(80, 25) | |
| 106 | + | d.paint(sess, ac, true, nil) | |
| 107 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |
| 108 | + | ||
| 109 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 110 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |
| 111 | + | require.True(t, client.CursorVisible(), "open palette shows the real cursor") | |
| 112 | + | row, col := client.CursorRow(), client.CursorCol() | |
| 113 | + | require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt") | |
| 114 | + | ||
| 115 | + | d.handleInput(sess, ac, []byte("ab")) | |
| 116 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |
| 117 | + | require.True(t, client.CursorVisible()) | |
| 118 | + | require.Equal(t, row, client.CursorRow()) | |
| 119 | + | require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query") | |
| 120 | + | require.Equal(t, 'b', client.Cell(col+1, row).Rune) | |
| 121 | + | } | |
| @@ -0,0 +1,121 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | vt "github.com/bnema/vev-vt" | |||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestOverlayInputCursor(t *testing.T) { | |||
| 13 | + | inner := domain.Rect{X: 3, Y: 2, Width: 10, Height: 4} | |||
| 14 | + | withCaret := func(col int) capturedModal { | |||
| 15 | + | return capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: col, hasCaret: true} | |||
| 16 | + | } | |||
| 17 | + | notices := capturedModal{active: true, presentation: ui.Presentation{Inner: inner}} | |||
| 18 | + | bar := capturedCursorInputs{style: 6, hasStyle: true, visible: true, renderable: true} | |||
| 19 | + | for _, tt := range []struct { | |||
| 20 | + | name string | |||
| 21 | + | overlays capturedOverlayRenderState | |||
| 22 | + | pane capturedCursorInputs | |||
| 23 | + | want cursorOut | |||
| 24 | + | }{ | |||
| 25 | + | { | |||
| 26 | + | name: "palette caret owns the cursor and keeps the pane shape", | |||
| 27 | + | overlays: capturedOverlayRenderState{palette: withCaret(4)}, | |||
| 28 | + | pane: bar, | |||
| 29 | + | want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true}, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | name: "hidden pane cursor keeps its app-set shape", | |||
| 33 | + | overlays: capturedOverlayRenderState{palette: withCaret(4)}, | |||
| 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}, | |||
| 41 | + | want: cursorOut{valid: true, row: 2, col: 7, style: 1, hasStyle: true}, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "prompt above palette owns the cursor", | |||
| 45 | + | overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)}, | |||
| 46 | + | pane: bar, | |||
| 47 | + | want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true}, | |||
| 48 | + | }, | |||
| 49 | + | { | |||
| 50 | + | name: "prompt above copy search owns the cursor", | |||
| 51 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1), prompt: withCaret(6)}, | |||
| 52 | + | pane: bar, | |||
| 53 | + | want: cursorOut{valid: true, row: 2, col: 9, style: 6, hasStyle: true}, | |||
| 54 | + | }, | |||
| 55 | + | { | |||
| 56 | + | name: "palette above notices owns the cursor", | |||
| 57 | + | overlays: capturedOverlayRenderState{palette: withCaret(4), noticesOverlay: notices}, | |||
| 58 | + | pane: bar, | |||
| 59 | + | want: cursorOut{valid: true, row: 2, col: 7, style: 6, hasStyle: true}, | |||
| 60 | + | }, | |||
| 61 | + | { | |||
| 62 | + | name: "copy search caret owns the cursor", | |||
| 63 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1)}, | |||
| 64 | + | pane: bar, | |||
| 65 | + | want: cursorOut{valid: true, row: 2, col: 4, style: 6, hasStyle: true}, | |||
| 66 | + | }, | |||
| 67 | + | { | |||
| 68 | + | name: "notices above copy search hide the cursor", | |||
| 69 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: notices}, | |||
| 70 | + | pane: bar, | |||
| 71 | + | want: cursorOut{hidden: true}, | |||
| 72 | + | }, | |||
| 73 | + | { | |||
| 74 | + | name: "modal without caret hides the cursor", | |||
| 75 | + | overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 4}}, | |||
| 76 | + | pane: bar, | |||
| 77 | + | want: cursorOut{hidden: true}, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | name: "caret outside the frame hides the cursor", | |||
| 81 | + | overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: domain.Rect{X: 18, Y: 2, Width: 10, Height: 1}}, hasCaret: true, caretCol: 4}}, | |||
| 82 | + | pane: bar, | |||
| 83 | + | want: cursorOut{hidden: true}, | |||
| 84 | + | }, | |||
| 85 | + | { | |||
| 86 | + | name: "copy mode without a modal hides the cursor", | |||
| 87 | + | overlays: capturedOverlayRenderState{copyActive: true}, | |||
| 88 | + | pane: bar, | |||
| 89 | + | want: cursorOut{hidden: true}, | |||
| 90 | + | }, | |||
| 91 | + | } { | |||
| 92 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 93 | + | require.Equal(t, tt.want, overlayInputCursor(tt.overlays, tt.pane, 20, 10)) | |||
| 94 | + | }) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // TestPaletteInputLineOwnsTerminalCursor drives the production render path: | |||
| 99 | + | // the open palette shows the real terminal cursor after its query, which is | |||
| 100 | + | // what lets client predictive echo type ahead in it. | |||
| 101 | + | func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) { | |||
| 102 | + | p, release := newBlockingPTY(t) | |||
| 103 | + | defer release() | |||
| 104 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 105 | + | client := vt.NewScreen(80, 25) | |||
| 106 | + | d.paint(sess, ac, true, nil) | |||
| 107 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |||
| 108 | + | ||||
| 109 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 110 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |||
| 111 | + | require.True(t, client.CursorVisible(), "open palette shows the real cursor") | |||
| 112 | + | row, col := client.CursorRow(), client.CursorCol() | |||
| 113 | + | require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt") | |||
| 114 | + | ||||
| 115 | + | d.handleInput(sess, ac, []byte("ab")) | |||
| 116 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |||
| 117 | + | require.True(t, client.CursorVisible()) | |||
| 118 | + | require.Equal(t, row, client.CursorRow()) | |||
| 119 | + | require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query") | |||
| 120 | + | require.Equal(t, 'b', client.Cell(col+1, row).Rune) | |||
| 121 | + | } | |||
M internal/usecase/daemon/render_capture.go +11 -8
| @@ -141,6 +141,10 @@ type capturedModal struct { | |||
| 141 | 141 | presentation ui.Presentation | |
| 142 | 142 | inner renderer.Frame | |
| 143 | 143 | focused bool | |
| 144 | + | // caretCol is the input caret column on row 0 of inner, valid when | |
| 145 | + | // hasCaret. The topmost modal with a caret owns the terminal cursor. | |
| 146 | + | caretCol int | |
| 147 | + | hasCaret bool | |
| 144 | 148 | } | |
| 145 | 149 | ||
| 146 | 150 | func (o capturedOverlayRenderState) active() bool { | |
| @@ -148,9 +152,9 @@ func (o capturedOverlayRenderState) active() bool { | |||
| 148 | 152 | } | |
| 149 | 153 | ||
| 150 | 154 | type capturedCursorInputs struct { | |
| 151 | - | row, col, style int | |
| 152 | - | hasStyle, visible, renderable, hiddenByOverlay bool | |
| 153 | - | content domain.Rect | |
| 155 | + | row, col, style int | |
| 156 | + | hasStyle, visible, renderable bool | |
| 157 | + | content domain.Rect | |
| 154 | 158 | } | |
| 155 | 159 | ||
| 156 | 160 | // writePaneScreenLocked is the production VT write boundary. The first | |
| @@ -407,7 +411,7 @@ func captureLocalRenderState( | |||
| 407 | 411 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | |
| 408 | 412 | captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus | |
| 409 | 413 | if captured.focused { | |
| 410 | - | state.cursor = captureCursorInputsLocked(p, placement.Content, overlays) | |
| 414 | + | state.cursor = captureCursorInputsLocked(p, placement.Content) | |
| 411 | 415 | } | |
| 412 | 416 | p.mu.Unlock() | |
| 413 | 417 | translated := captured.rawDamage[:0] | |
| @@ -444,7 +448,7 @@ func captureLocalRenderState( | |||
| 444 | 448 | // A visible floating pane is the terminal input target, so its structural | |
| 445 | 449 | // border carries the focused semantic role independently of its content. | |
| 446 | 450 | state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 447 | - | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| 451 | + | state.cursor = captureCursorInputsLocked(p, geometry.Inner) | |
| 448 | 452 | p.mu.Unlock() | |
| 449 | 453 | } | |
| 450 | 454 | scratch.receipts = state.receipts | |
| @@ -469,10 +473,9 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |||
| 469 | 473 | return append(dst[:0], src...) | |
| 470 | 474 | } | |
| 471 | 475 | ||
| 472 | - | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | |
| 476 | + | func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs { | |
| 473 | 477 | style, hasStyle := p.screen.CursorStyle() | |
| 474 | - | hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive | |
| 475 | - | 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} | |
| 476 | 479 | } | |
| 477 | 480 | ||
| 478 | 481 | // appendStackPaneIDs returns exactly the panes whose solved placement can own a | |
| @@ -141,6 +141,10 @@ type capturedModal struct { | |||||
| 141 | presentation ui.Presentation | 141 | presentation ui.Presentation | ||
| 142 | inner renderer.Frame | 142 | inner renderer.Frame | ||
| 143 | focused bool | 143 | focused bool | ||
| 144 | + | // caretCol is the input caret column on row 0 of inner, valid when | |||
| 145 | + | // hasCaret. The topmost modal with a caret owns the terminal cursor. | |||
| 146 | + | caretCol int | |||
| 147 | + | hasCaret bool | |||
| 144 | } | 148 | } | ||
| 145 | 149 | ||||
| 146 | func (o capturedOverlayRenderState) active() bool { | 150 | func (o capturedOverlayRenderState) active() bool { | ||
| @@ -148,9 +152,9 @@ func (o capturedOverlayRenderState) active() bool { | |||||
| 148 | } | 152 | } | ||
| 149 | 153 | ||||
| 150 | type capturedCursorInputs struct { | 154 | type capturedCursorInputs struct { | ||
| 151 | - | row, col, style int | 155 | + | row, col, style int |
| 152 | - | hasStyle, visible, renderable, hiddenByOverlay bool | 156 | + | hasStyle, visible, renderable bool |
| 153 | - | content domain.Rect | 157 | + | content domain.Rect |
| 154 | } | 158 | } | ||
| 155 | 159 | ||||
| 156 | // writePaneScreenLocked is the production VT write boundary. The first | 160 | // writePaneScreenLocked is the production VT write boundary. The first | ||
| @@ -407,7 +411,7 @@ func captureLocalRenderState( | |||||
| 407 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | 411 | state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) | ||
| 408 | captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus | 412 | captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus | ||
| 409 | if captured.focused { | 413 | if captured.focused { | ||
| 410 | - | state.cursor = captureCursorInputsLocked(p, placement.Content, overlays) | 414 | + | state.cursor = captureCursorInputsLocked(p, placement.Content) |
| 411 | } | 415 | } | ||
| 412 | p.mu.Unlock() | 416 | p.mu.Unlock() | ||
| 413 | translated := captured.rawDamage[:0] | 417 | translated := captured.rawDamage[:0] | ||
| @@ -444,7 +448,7 @@ func captureLocalRenderState( | |||||
| 444 | // 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 | ||
| 445 | // border carries the focused semantic role independently of its content. | 449 | // border carries the focused semantic role independently of its content. | ||
| 446 | 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} | ||
| 447 | - | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | 451 | + | state.cursor = captureCursorInputsLocked(p, geometry.Inner) |
| 448 | p.mu.Unlock() | 452 | p.mu.Unlock() | ||
| 449 | } | 453 | } | ||
| 450 | scratch.receipts = state.receipts | 454 | scratch.receipts = state.receipts | ||
| @@ -469,10 +473,9 @@ func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { | |||||
| 469 | return append(dst[:0], src...) | 473 | return append(dst[:0], src...) | ||
| 470 | } | 474 | } | ||
| 471 | 475 | ||||
| 472 | - | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | 476 | + | func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs { |
| 473 | style, hasStyle := p.screen.CursorStyle() | 477 | style, hasStyle := p.screen.CursorStyle() | ||
| 474 | - | 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} |
| 475 | - | 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} | |||
| 476 | } | 479 | } | ||
| 477 | 480 | ||||
| 478 | // 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 +31 -5
| @@ -244,9 +244,10 @@ 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) | |
| 248 | + | if overlaysActive { | |
| 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 | 252 | // never into baseFrame. Keep | |
| 252 | 253 | // that unadorned base reusable between wheel events. Modal/floating paths | |
| @@ -280,7 +281,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s | |||
| 280 | 281 | } | |
| 281 | 282 | ||
| 282 | 283 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |
| 283 | - | if !c.renderable || c.hiddenByOverlay || !c.visible { | |
| 284 | + | if !c.renderable || !c.visible { | |
| 284 | 285 | return cursorOut{hidden: true} | |
| 285 | 286 | } | |
| 286 | 287 | style := c.style | |
| @@ -290,6 +291,29 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |||
| 290 | 291 | return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |
| 291 | 292 | } | |
| 292 | 293 | ||
| 294 | + | // overlayInputCursor places the terminal cursor on the input caret of the | |
| 295 | + | // topmost modal, in the same priority order composeCapturedOverlays paints. | |
| 296 | + | // 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 keeps | |
| 298 | + | // the focused pane's cursor shape, so opening a modal never changes it. | |
| 299 | + | func overlayInputCursor(o capturedOverlayRenderState, pane capturedCursorInputs, width, height int) cursorOut { | |
| 300 | + | for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} { | |
| 301 | + | if !modal.active { | |
| 302 | + | continue | |
| 303 | + | } | |
| 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 { | |
| 306 | + | break | |
| 307 | + | } | |
| 308 | + | style := 1 | |
| 309 | + | if pane.hasStyle { | |
| 310 | + | style = pane.style | |
| 311 | + | } | |
| 312 | + | return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true} | |
| 313 | + | } | |
| 314 | + | return cursorOut{hidden: true} | |
| 315 | + | } | |
| 316 | + | ||
| 293 | 317 | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | |
| 294 | 318 | if state == nil || snap == nil { | |
| 295 | 319 | return | |
| @@ -313,6 +337,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 313 | 337 | presentation := copySearchModal.Resolve(size) | |
| 314 | 338 | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | |
| 315 | 339 | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | |
| 340 | + | o.copySearch.caretCol, o.copySearch.hasCaret = snap.copySearchModel.Caret(rectSize(presentation.Inner)) | |
| 316 | 341 | } | |
| 317 | 342 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |
| 318 | 343 | presentation := noticesModal.Resolve(size) | |
| @@ -330,6 +355,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 330 | 355 | } | |
| 331 | 356 | o.paletteGuidance = snap.paletteFeedback | |
| 332 | 357 | o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback}) | |
| 358 | + | o.palette.caretCol, o.palette.hasCaret = snap.paletteModel.Caret(rectSize(presentation.Inner)) | |
| 333 | 359 | } | |
| 334 | 360 | if snap.promptActive && snap.promptModel != nil { | |
| 335 | 361 | modal := promptModalFor(snap.promptModel.Title()) | |
| @@ -341,8 +367,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 341 | 367 | selection = styles.SurfaceActive | |
| 342 | 368 | } | |
| 343 | 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)) | |
| 344 | 371 | } | |
| 345 | - | state.cursor.hiddenByOverlay = o.active() | |
| 346 | 372 | } | |
| 347 | 373 | ||
| 348 | 374 | func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { | |
| @@ -244,9 +244,10 @@ 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 | 248 | + | if overlaysActive { |
| 249 | - | cursor := desiredCapturedCursor(cursorInputs, contentY) | 249 | + | cursor = overlayInputCursor(state.overlays, state.cursor, width, frameHeight) |
| 250 | + | } | |||
| 250 | // 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, | ||
| 251 | // never into baseFrame. Keep | 252 | // never into baseFrame. Keep | ||
| 252 | // that unadorned base reusable between wheel events. Modal/floating paths | 253 | // that unadorned base reusable between wheel events. Modal/floating paths | ||
| @@ -280,7 +281,7 @@ func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title s | |||||
| 280 | } | 281 | } | ||
| 281 | 282 | ||||
| 282 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | 283 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | ||
| 283 | - | if !c.renderable || c.hiddenByOverlay || !c.visible { | 284 | + | if !c.renderable || !c.visible { |
| 284 | return cursorOut{hidden: true} | 285 | return cursorOut{hidden: true} | ||
| 285 | } | 286 | } | ||
| 286 | style := c.style | 287 | style := c.style | ||
| @@ -290,6 +291,29 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |||||
| 290 | return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | 291 | return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | ||
| 291 | } | 292 | } | ||
| 292 | 293 | ||||
| 294 | + | // overlayInputCursor places the terminal cursor on the input caret of the | |||
| 295 | + | // topmost modal, in the same priority order composeCapturedOverlays paints. | |||
| 296 | + | // 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 keeps | |||
| 298 | + | // the focused pane's cursor shape, so opening a modal never changes it. | |||
| 299 | + | func overlayInputCursor(o capturedOverlayRenderState, pane capturedCursorInputs, width, height int) cursorOut { | |||
| 300 | + | for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} { | |||
| 301 | + | if !modal.active { | |||
| 302 | + | continue | |||
| 303 | + | } | |||
| 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 { | |||
| 306 | + | break | |||
| 307 | + | } | |||
| 308 | + | style := 1 | |||
| 309 | + | if pane.hasStyle { | |||
| 310 | + | style = pane.style | |||
| 311 | + | } | |||
| 312 | + | return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true} | |||
| 313 | + | } | |||
| 314 | + | return cursorOut{hidden: true} | |||
| 315 | + | } | |||
| 316 | + | ||||
| 293 | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | 317 | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | ||
| 294 | if state == nil || snap == nil { | 318 | if state == nil || snap == nil { | ||
| 295 | return | 319 | return | ||
| @@ -313,6 +337,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 313 | presentation := copySearchModal.Resolve(size) | 337 | presentation := copySearchModal.Resolve(size) | ||
| 314 | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | 338 | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | ||
| 315 | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | 339 | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | ||
| 340 | + | o.copySearch.caretCol, o.copySearch.hasCaret = snap.copySearchModel.Caret(rectSize(presentation.Inner)) | |||
| 316 | } | 341 | } | ||
| 317 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | 342 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | ||
| 318 | presentation := noticesModal.Resolve(size) | 343 | presentation := noticesModal.Resolve(size) | ||
| @@ -330,6 +355,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 330 | } | 355 | } | ||
| 331 | o.paletteGuidance = snap.paletteFeedback | 356 | o.paletteGuidance = snap.paletteFeedback | ||
| 332 | o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback}) | 357 | o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback}) | ||
| 358 | + | o.palette.caretCol, o.palette.hasCaret = snap.paletteModel.Caret(rectSize(presentation.Inner)) | |||
| 333 | } | 359 | } | ||
| 334 | if snap.promptActive && snap.promptModel != nil { | 360 | if snap.promptActive && snap.promptModel != nil { | ||
| 335 | modal := promptModalFor(snap.promptModel.Title()) | 361 | modal := promptModalFor(snap.promptModel.Title()) | ||
| @@ -341,8 +367,8 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 341 | selection = styles.SurfaceActive | 367 | selection = styles.SurfaceActive | ||
| 342 | } | 368 | } | ||
| 343 | 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)) | |||
| 344 | } | 371 | } | ||
| 345 | - | state.cursor.hiddenByOverlay = o.active() | |||
| 346 | } | 372 | } | ||
| 347 | 373 | ||||
| 348 | 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 +11 -2
| @@ -127,6 +127,15 @@ func (m *Model) Query() string { | |||
| 127 | 127 | } | |
| 128 | 128 | return m.input.Value() | |
| 129 | 129 | } | |
| 130 | + | ||
| 131 | + | // paletteInputPrefix leads the query line on row 0 of the rendered frame. | |
| 132 | + | const paletteInputPrefix = "> " | |
| 133 | + | ||
| 134 | + | // Caret is the query caret column on row 0 of a Render frame of size inner | |
| 135 | + | // (see ui.InputCaret). | |
| 136 | + | func (m *Model) Caret(inner domain.Size) (int, bool) { | |
| 137 | + | return ui.InputCaret(inner, paletteInputPrefix, m.Query()) | |
| 138 | + | } | |
| 130 | 139 | func (m *Model) Up() { | |
| 131 | 140 | if m == nil || len(m.matches) == 0 { | |
| 132 | 141 | return | |
| @@ -371,10 +380,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 371 | 380 | } | |
| 372 | 381 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 373 | 382 | if m == nil { | |
| 374 | - | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| 383 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base) | |
| 375 | 384 | return frame | |
| 376 | 385 | } | |
| 377 | - | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | |
| 386 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base) | |
| 378 | 387 | start, visible := 1, frame.Height-1 | |
| 379 | 388 | if visible <= 0 || len(m.matches) == 0 { | |
| 380 | 389 | return frame | |
| @@ -127,6 +127,15 @@ func (m *Model) Query() string { | |||||
| 127 | } | 127 | } | ||
| 128 | return m.input.Value() | 128 | return m.input.Value() | ||
| 129 | } | 129 | } | ||
| 130 | + | ||||
| 131 | + | // paletteInputPrefix leads the query line on row 0 of the rendered frame. | |||
| 132 | + | const paletteInputPrefix = "> " | |||
| 133 | + | ||||
| 134 | + | // Caret is the query caret column on row 0 of a Render frame of size inner | |||
| 135 | + | // (see ui.InputCaret). | |||
| 136 | + | func (m *Model) Caret(inner domain.Size) (int, bool) { | |||
| 137 | + | return ui.InputCaret(inner, paletteInputPrefix, m.Query()) | |||
| 138 | + | } | |||
| 130 | func (m *Model) Up() { | 139 | func (m *Model) Up() { | ||
| 131 | if m == nil || len(m.matches) == 0 { | 140 | if m == nil || len(m.matches) == 0 { | ||
| 132 | return | 141 | return | ||
| @@ -371,10 +380,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||||
| 371 | } | 380 | } | ||
| 372 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 381 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 373 | if m == nil { | 382 | if m == nil { | ||
| 374 | - | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | 383 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base) |
| 375 | return frame | 384 | return frame | ||
| 376 | } | 385 | } | ||
| 377 | - | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | 386 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base) |
| 378 | start, visible := 1, frame.Height-1 | 387 | start, visible := 1, frame.Height-1 | ||
| 379 | if visible <= 0 || len(m.matches) == 0 { | 388 | if visible <= 0 || len(m.matches) == 0 { | ||
| 380 | return frame | 389 | return frame | ||
M internal/usecase/palette/model_test.go +4 -1
| @@ -314,7 +314,10 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |||
| 314 | 314 | require.Equal(t, ' ', frame.At(1, 0).Rune) | |
| 315 | 315 | require.Equal(t, 'c', frame.At(2, 0).Rune) | |
| 316 | 316 | require.Equal(t, 'y', frame.At(3, 0).Rune) | |
| 317 | - | require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query") | |
| 317 | + | require.False(t, frame.At(4, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn") | |
| 318 | + | col, ok := m.Caret(domain.Size{Cols: 28, Rows: 3}) | |
| 319 | + | require.True(t, ok) | |
| 320 | + | require.Equal(t, 4, col, "caret follows the query") | |
| 318 | 321 | ||
| 319 | 322 | require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1)) | |
| 320 | 323 | require.NotContains(t, frameRow(frame, 1), "Copy") | |
| @@ -314,7 +314,10 @@ func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) { | |||||
| 314 | require.Equal(t, ' ', frame.At(1, 0).Rune) | 314 | require.Equal(t, ' ', frame.At(1, 0).Rune) | ||
| 315 | require.Equal(t, 'c', frame.At(2, 0).Rune) | 315 | require.Equal(t, 'c', frame.At(2, 0).Rune) | ||
| 316 | require.Equal(t, 'y', frame.At(3, 0).Rune) | 316 | require.Equal(t, 'y', frame.At(3, 0).Rune) | ||
| 317 | - | require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query") | 317 | + | require.False(t, frame.At(4, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn") |
| 318 | + | col, ok := m.Caret(domain.Size{Cols: 28, Rows: 3}) | |||
| 319 | + | require.True(t, ok) | |||
| 320 | + | require.Equal(t, 4, col, "caret follows the query") | |||
| 318 | 321 | ||||
| 319 | require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1)) | 322 | require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1)) | ||
| 320 | require.NotContains(t, frameRow(frame, 1), "Copy") | 323 | require.NotContains(t, frameRow(frame, 1), "Copy") | ||
M internal/usecase/prompt/model.go +10 -1
| @@ -49,6 +49,15 @@ func (m *Model) Value() string { | |||
| 49 | 49 | return m.text.Value() | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | // promptInputPrefix leads the value line on row 0 of the rendered frame. | |
| 53 | + | const promptInputPrefix = "> " | |
| 54 | + | ||
| 55 | + | // Caret is the value caret column on row 0 of a Render frame of size inner | |
| 56 | + | // (see ui.InputCaret). | |
| 57 | + | func (m *Model) Caret(inner domain.Size) (int, bool) { | |
| 58 | + | return ui.InputCaret(inner, promptInputPrefix, m.Value()) | |
| 59 | + | } | |
| 60 | + | ||
| 52 | 61 | func (m *Model) Title() string { | |
| 53 | 62 | if m == nil { | |
| 54 | 63 | return "" | |
| @@ -79,7 +88,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||
| 79 | 88 | return frame | |
| 80 | 89 | } | |
| 81 | 90 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base}) | |
| 82 | - | ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection) | |
| 91 | + | ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base) | |
| 83 | 92 | if frame.Height > 1 && m != nil && m.errMsg != "" { | |
| 84 | 93 | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection) | |
| 85 | 94 | } | |
| @@ -49,6 +49,15 @@ func (m *Model) Value() string { | |||||
| 49 | return m.text.Value() | 49 | return m.text.Value() | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | // promptInputPrefix leads the value line on row 0 of the rendered frame. | |||
| 53 | + | const promptInputPrefix = "> " | |||
| 54 | + | ||||
| 55 | + | // Caret is the value caret column on row 0 of a Render frame of size inner | |||
| 56 | + | // (see ui.InputCaret). | |||
| 57 | + | func (m *Model) Caret(inner domain.Size) (int, bool) { | |||
| 58 | + | return ui.InputCaret(inner, promptInputPrefix, m.Value()) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 52 | func (m *Model) Title() string { | 61 | func (m *Model) Title() string { | ||
| 53 | if m == nil { | 62 | if m == nil { | ||
| 54 | return "" | 63 | return "" | ||
| @@ -79,7 +88,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||||
| 79 | return frame | 88 | return frame | ||
| 80 | } | 89 | } | ||
| 81 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base}) | 90 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base}) | ||
| 82 | - | ui.DrawInputLine(frame, 0, "> ", m.Value(), styles.Base, styles.Selection) | 91 | + | ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base) |
| 83 | if frame.Height > 1 && m != nil && m.errMsg != "" { | 92 | if frame.Height > 1 && m != nil && m.errMsg != "" { | ||
| 84 | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection) | 93 | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection) | ||
| 85 | } | 94 | } | ||
M internal/usecase/prompt/model_test.go +5 -2
| @@ -32,7 +32,10 @@ func TestModelRenderDrawsInputAndError(t *testing.T) { | |||
| 32 | 32 | frame := m.Render(domain.Size{Cols: 24, Rows: 2}) | |
| 33 | 33 | ||
| 34 | 34 | require.Equal(t, "> work ", promptRowText(frame.Row(0))) | |
| 35 | - | require.True(t, frame.At(6, 0).Style.Inverse, "caret follows prefilled value") | |
| 35 | + | require.False(t, frame.At(6, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn") | |
| 36 | + | col, ok := m.Caret(domain.Size{Cols: 24, Rows: 2}) | |
| 37 | + | require.True(t, ok) | |
| 38 | + | require.Equal(t, 6, col, "caret follows prefilled value") | |
| 36 | 39 | require.Equal(t, "name already in use ", promptRowText(frame.Row(1))) | |
| 37 | 40 | require.True(t, frame.At(0, 1).Style.Inverse, "error line is visually distinct") | |
| 38 | 41 | } | |
| @@ -65,5 +68,5 @@ func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) { | |||
| 65 | 68 | frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection}) | |
| 66 | 69 | ||
| 67 | 70 | require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface") | |
| 68 | - | require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface") | |
| 71 | + | require.True(t, frame.At(3, 0).Style.Equal(base), "caret cell keeps base surface under the real cursor") | |
| 69 | 72 | } | |
| @@ -32,7 +32,10 @@ func TestModelRenderDrawsInputAndError(t *testing.T) { | |||||
| 32 | frame := m.Render(domain.Size{Cols: 24, Rows: 2}) | 32 | frame := m.Render(domain.Size{Cols: 24, Rows: 2}) | ||
| 33 | 33 | ||||
| 34 | require.Equal(t, "> work ", promptRowText(frame.Row(0))) | 34 | require.Equal(t, "> work ", promptRowText(frame.Row(0))) | ||
| 35 | - | require.True(t, frame.At(6, 0).Style.Inverse, "caret follows prefilled value") | 35 | + | require.False(t, frame.At(6, 0).Style.Inverse, "the real terminal cursor is the caret, none is drawn") |
| 36 | + | col, ok := m.Caret(domain.Size{Cols: 24, Rows: 2}) | |||
| 37 | + | require.True(t, ok) | |||
| 38 | + | require.Equal(t, 6, col, "caret follows prefilled value") | |||
| 36 | require.Equal(t, "name already in use ", promptRowText(frame.Row(1))) | 39 | require.Equal(t, "name already in use ", promptRowText(frame.Row(1))) | ||
| 37 | require.True(t, frame.At(0, 1).Style.Inverse, "error line is visually distinct") | 40 | require.True(t, frame.At(0, 1).Style.Inverse, "error line is visually distinct") | ||
| 38 | } | 41 | } | ||
| @@ -65,5 +68,5 @@ func TestModelRenderStyledFillsBaseAndSelection(t *testing.T) { | |||||
| 65 | frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection}) | 68 | frame := m.RenderStyled(domain.Size{Cols: 12, Rows: 2}, RenderStyles{Base: base, Selection: selection}) | ||
| 66 | 69 | ||||
| 67 | require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface") | 70 | require.True(t, frame.At(11, 1).Style.Equal(base), "blank filler keeps base surface") | ||
| 68 | - | require.True(t, frame.At(3, 0).Style.Equal(selection), "caret keeps selection surface") | 71 | + | require.True(t, frame.At(3, 0).Style.Equal(base), "caret cell keeps base surface under the real cursor") |
| 69 | } | 72 | } | ||
M internal/usecase/ui/textinput.go +20 -11
| @@ -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 { | |
| @@ -20,18 +23,24 @@ func (t *TextInput) Value() string { return string(t.runes) } | |||
| 20 | 23 | ||
| 21 | 24 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | |
| 22 | 25 | ||
| 23 | - | // DrawInputLine draws prefix + value followed by a reverse-video caret cell. | |
| 24 | - | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) { | |
| 26 | + | // DrawInputLine draws prefix + value. The caret is not drawn: the daemon | |
| 27 | + | // places the real terminal cursor at InputCaret, so client predictive echo | |
| 28 | + | // can type ahead on the input line. | |
| 29 | + | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) { | |
| 25 | 30 | if y < 0 || y >= f.Height { | |
| 26 | 31 | return | |
| 27 | 32 | } | |
| 28 | - | x := DrawText(f, 0, y, f.Width, prefix+value, style) | |
| 29 | - | if x >= 0 && x < f.Width { | |
| 30 | - | caret := style | |
| 31 | - | caret.Inverse = true | |
| 32 | - | if len(caretStyle) > 0 { | |
| 33 | - | caret = caretStyle[0] | |
| 34 | - | } | |
| 35 | - | f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret}) | |
| 33 | + | DrawText(f, 0, y, f.Width, prefix+value, style) | |
| 34 | + | } | |
| 35 | + | ||
| 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) { | |
| 41 | + | col := 0 | |
| 42 | + | for _, r := range prefix + value { | |
| 43 | + | col += renderer.RuneWidth(r) | |
| 36 | 44 | } | |
| 45 | + | return col, inner.Rows > 0 && col < inner.Cols | |
| 37 | 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 { | ||
| @@ -20,18 +23,24 @@ func (t *TextInput) Value() string { return string(t.runes) } | |||||
| 20 | 23 | ||||
| 21 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | 24 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | ||
| 22 | 25 | ||||
| 23 | - | // DrawInputLine draws prefix + value followed by a reverse-video caret cell. | 26 | + | // DrawInputLine draws prefix + value. The caret is not drawn: the daemon |
| 24 | - | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style, caretStyle ...renderer.Style) { | 27 | + | // places the real terminal cursor at InputCaret, so client predictive echo |
| 28 | + | // can type ahead on the input line. | |||
| 29 | + | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) { | |||
| 25 | if y < 0 || y >= f.Height { | 30 | if y < 0 || y >= f.Height { | ||
| 26 | return | 31 | return | ||
| 27 | } | 32 | } | ||
| 28 | - | x := DrawText(f, 0, y, f.Width, prefix+value, style) | 33 | + | DrawText(f, 0, y, f.Width, prefix+value, style) |
| 29 | - | if x >= 0 && x < f.Width { | 34 | + | } |
| 30 | - | caret := style | 35 | + | |
| 31 | - | caret.Inverse = true | 36 | + | // InputCaret is the caret column of an input line drawn by DrawInputLine on |
| 32 | - | if len(caretStyle) > 0 { | 37 | + | // row 0 of a frame of size inner. It reports false when the frame has no row |
| 33 | - | caret = caretStyle[0] | 38 | + | // or prefix + value leaves no room for the caret, so a clipped line never |
| 34 | - | } | 39 | + | // shows a misplaced cursor. |
| 35 | - | f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret}) | 40 | + | func InputCaret(inner domain.Size, prefix, value string) (int, bool) { |
| 41 | + | col := 0 | |||
| 42 | + | for _, r := range prefix + value { | |||
| 43 | + | col += renderer.RuneWidth(r) | |||
| 36 | } | 44 | } | ||
| 45 | + | return col, inner.Rows > 0 && col < inner.Cols | |||
| 37 | } | 46 | } | ||
M internal/usecase/ui/textinput_test.go +24 -14
| @@ -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 | ||
| @@ -22,7 +23,7 @@ func TestTextInputEditsValue(t *testing.T) { | |||
| 22 | 23 | require.Equal(t, "ship", input.Value()) | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | - | func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | |
| 26 | + | func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) { | |
| 26 | 27 | frame := renderer.NewFrame(6, 1) | |
| 27 | 28 | style := renderer.DefaultStyle() | |
| 28 | 29 | ||
| @@ -33,21 +34,30 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | |||
| 33 | 34 | require.Equal(t, 'a', frame.At(2, 0).Rune) | |
| 34 | 35 | require.Equal(t, 'b', frame.At(3, 0).Rune) | |
| 35 | 36 | require.Equal(t, 'c', frame.At(4, 0).Rune) | |
| 36 | - | require.Equal(t, ' ', frame.At(5, 0).Rune) | |
| 37 | - | require.True(t, frame.At(5, 0).Style.Inverse) | |
| 37 | + | require.False(t, frame.At(5, 0).Style.Inverse, "the real terminal cursor is the caret") | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 40 | - | func TestDrawInputLineClipsCaret(t *testing.T) { | |
| 41 | - | frame := renderer.NewFrame(4, 1) | |
| 42 | - | style := renderer.DefaultStyle() | |
| 43 | - | ||
| 44 | - | DrawInputLine(frame, 0, "> ", "abcdef", style) | |
| 45 | - | ||
| 46 | - | require.Equal(t, '>', frame.At(0, 0).Rune) | |
| 47 | - | require.Equal(t, ' ', frame.At(1, 0).Rune) | |
| 48 | - | require.Equal(t, 'a', frame.At(2, 0).Rune) | |
| 49 | - | require.Equal(t, 'b', frame.At(3, 0).Rune) | |
| 50 | - | require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell") | |
| 40 | + | func TestInputCaret(t *testing.T) { | |
| 41 | + | for _, tt := range []struct { | |
| 42 | + | name string | |
| 43 | + | width, rows int | |
| 44 | + | prefix, value string | |
| 45 | + | wantCol int | |
| 46 | + | wantOK bool | |
| 47 | + | }{ | |
| 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}, | |
| 54 | + | } { | |
| 55 | + | t.Run(tt.name, func(t *testing.T) { | |
| 56 | + | col, ok := InputCaret(domain.Size{Cols: tt.width, Rows: tt.rows}, tt.prefix, tt.value) | |
| 57 | + | require.Equal(t, tt.wantCol, col) | |
| 58 | + | require.Equal(t, tt.wantOK, ok) | |
| 59 | + | }) | |
| 60 | + | } | |
| 51 | 61 | } | |
| 52 | 62 | ||
| 53 | 63 | func TestDrawInputLineIgnoresInvalidRow(t *testing.T) { | |
| @@ -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 | ||||
| @@ -22,7 +23,7 @@ func TestTextInputEditsValue(t *testing.T) { | |||||
| 22 | require.Equal(t, "ship", input.Value()) | 23 | require.Equal(t, "ship", input.Value()) | ||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | - | func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | 26 | + | func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) { |
| 26 | frame := renderer.NewFrame(6, 1) | 27 | frame := renderer.NewFrame(6, 1) | ||
| 27 | style := renderer.DefaultStyle() | 28 | style := renderer.DefaultStyle() | ||
| 28 | 29 | ||||
| @@ -33,21 +34,30 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | |||||
| 33 | require.Equal(t, 'a', frame.At(2, 0).Rune) | 34 | require.Equal(t, 'a', frame.At(2, 0).Rune) | ||
| 34 | require.Equal(t, 'b', frame.At(3, 0).Rune) | 35 | require.Equal(t, 'b', frame.At(3, 0).Rune) | ||
| 35 | require.Equal(t, 'c', frame.At(4, 0).Rune) | 36 | require.Equal(t, 'c', frame.At(4, 0).Rune) | ||
| 36 | - | require.Equal(t, ' ', frame.At(5, 0).Rune) | 37 | + | require.False(t, frame.At(5, 0).Style.Inverse, "the real terminal cursor is the caret") |
| 37 | - | require.True(t, frame.At(5, 0).Style.Inverse) | |||
| 38 | } | 38 | } | ||
| 39 | 39 | ||||
| 40 | - | func TestDrawInputLineClipsCaret(t *testing.T) { | 40 | + | func TestInputCaret(t *testing.T) { |
| 41 | - | frame := renderer.NewFrame(4, 1) | 41 | + | for _, tt := range []struct { |
| 42 | - | style := renderer.DefaultStyle() | 42 | + | name string |
| 43 | - | 43 | + | width, rows int | |
| 44 | - | DrawInputLine(frame, 0, "> ", "abcdef", style) | 44 | + | prefix, value string |
| 45 | - | 45 | + | wantCol int | |
| 46 | - | require.Equal(t, '>', frame.At(0, 0).Rune) | 46 | + | wantOK bool |
| 47 | - | require.Equal(t, ' ', frame.At(1, 0).Rune) | 47 | + | }{ |
| 48 | - | require.Equal(t, 'a', frame.At(2, 0).Rune) | 48 | + | {name: "after value", width: 6, rows: 1, prefix: "> ", value: "abc", wantCol: 5, wantOK: true}, |
| 49 | - | require.Equal(t, 'b', frame.At(3, 0).Rune) | 49 | + | {name: "empty value", width: 6, rows: 1, prefix: "/", wantCol: 1, wantOK: true}, |
| 50 | - | require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell") | 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}, | |||
| 54 | + | } { | |||
| 55 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 56 | + | col, ok := InputCaret(domain.Size{Cols: tt.width, Rows: tt.rows}, tt.prefix, tt.value) | |||
| 57 | + | require.Equal(t, tt.wantCol, col) | |||
| 58 | + | require.Equal(t, tt.wantOK, ok) | |||
| 59 | + | }) | |||
| 60 | + | } | |||
| 51 | } | 61 | } | ||
| 52 | 62 | ||||
| 53 | func TestDrawInputLineIgnoresInvalidRow(t *testing.T) { | 63 | func TestDrawInputLineIgnoresInvalidRow(t *testing.T) { | ||
M internal/usecase/visualsearch/model.go +10 -1
| @@ -70,6 +70,15 @@ func (m *Model) Query() string { | |||
| 70 | 70 | return m.input.Value() | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | // searchInputPrefix leads the query line on row 0 of the rendered frame. | |
| 74 | + | const searchInputPrefix = "/" | |
| 75 | + | ||
| 76 | + | // Caret is the query caret column on row 0 of a Render frame of size inner | |
| 77 | + | // (see ui.InputCaret). | |
| 78 | + | func (m *Model) Caret(inner domain.Size) (int, bool) { | |
| 79 | + | return ui.InputCaret(inner, searchInputPrefix, m.Query()) | |
| 80 | + | } | |
| 81 | + | ||
| 73 | 82 | func (m *Model) Up() { | |
| 74 | 83 | if m != nil && m.selected > 0 { | |
| 75 | 84 | m.selected-- | |
| @@ -180,7 +189,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||
| 180 | 189 | } | |
| 181 | 190 | base, selection := styles.Base, styles.Selection | |
| 182 | 191 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 183 | - | ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection) | |
| 192 | + | ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base) | |
| 184 | 193 | visible := frame.Height - 1 | |
| 185 | 194 | if visible <= 0 || len(m.matches) == 0 { | |
| 186 | 195 | return frame | |
| @@ -70,6 +70,15 @@ func (m *Model) Query() string { | |||||
| 70 | return m.input.Value() | 70 | return m.input.Value() | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | + | // searchInputPrefix leads the query line on row 0 of the rendered frame. | |||
| 74 | + | const searchInputPrefix = "/" | |||
| 75 | + | ||||
| 76 | + | // Caret is the query caret column on row 0 of a Render frame of size inner | |||
| 77 | + | // (see ui.InputCaret). | |||
| 78 | + | func (m *Model) Caret(inner domain.Size) (int, bool) { | |||
| 79 | + | return ui.InputCaret(inner, searchInputPrefix, m.Query()) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 73 | func (m *Model) Up() { | 82 | func (m *Model) Up() { | ||
| 74 | if m != nil && m.selected > 0 { | 83 | if m != nil && m.selected > 0 { | ||
| 75 | m.selected-- | 84 | m.selected-- | ||
| @@ -180,7 +189,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||||
| 180 | } | 189 | } | ||
| 181 | base, selection := styles.Base, styles.Selection | 190 | base, selection := styles.Base, styles.Selection | ||
| 182 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 191 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 183 | - | ui.DrawInputLine(frame, 0, "/", m.Query(), base, selection) | 192 | + | ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base) |
| 184 | visible := frame.Height - 1 | 193 | visible := frame.Height - 1 | ||
| 185 | if visible <= 0 || len(m.matches) == 0 { | 194 | if visible <= 0 || len(m.matches) == 0 { | ||
| 186 | return frame | 195 | return frame | ||
M internal/usecase/visualsearch/model_test.go +1 -1
| @@ -234,7 +234,7 @@ func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) { | |||
| 234 | 234 | why string | |
| 235 | 235 | }{ | |
| 236 | 236 | {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"}, | |
| 237 | - | {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"}, | |
| 237 | + | {name: "input caret", x: 2, y: 0, want: base, why: "caret cell keeps base surface under the real cursor"}, | |
| 238 | 238 | } { | |
| 239 | 239 | t.Run(tt.name, func(t *testing.T) { | |
| 240 | 240 | m := New(testSnapshot("alpha")) | |
| @@ -234,7 +234,7 @@ func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) { | |||||
| 234 | why string | 234 | why string | ||
| 235 | }{ | 235 | }{ | ||
| 236 | {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"}, | 236 | {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"}, | ||
| 237 | - | {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"}, | 237 | + | {name: "input caret", x: 2, y: 0, want: base, why: "caret cell keeps base surface under the real cursor"}, |
| 238 | } { | 238 | } { | ||
| 239 | t.Run(tt.name, func(t *testing.T) { | 239 | t.Run(tt.name, func(t *testing.T) { | ||
| 240 | m := New(testSnapshot("alpha")) | 240 | m := New(testSnapshot("alpha")) | ||