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.
- commit
- 9f742a8370edd705b868c2c2a08f1c4da4a1dfd3
- author
- brice · · 2026-10-07 20:18 UTC
- parent
- 76d7fa2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +229 -34
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_test.go +22 -0
| @@ -378,6 +378,28 @@ 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 | + | ||
| 381 | 403 | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | |
| 382 | 404 | t.Parallel() | |
| 383 | 405 | var f apcFilter | |
| @@ -378,6 +378,28 @@ 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 | + | ||||
| 381 | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | 403 | func TestApcFilterStripsGraphicsAcrossOutputs(t *testing.T) { | ||
| 382 | t.Parallel() | 404 | t.Parallel() | ||
| 383 | var f apcFilter | 405 | var f apcFilter | ||
A internal/usecase/daemon/overlay_cursor_test.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 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 | + | for _, tt := range []struct { | |
| 18 | + | name string | |
| 19 | + | overlays capturedOverlayRenderState | |
| 20 | + | want cursorOut | |
| 21 | + | }{ | |
| 22 | + | { | |
| 23 | + | name: "palette caret owns the cursor", | |
| 24 | + | overlays: capturedOverlayRenderState{palette: withCaret(4)}, | |
| 25 | + | want: cursorOut{valid: true, row: 2, col: 7, hasStyle: true}, | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "prompt above palette owns the cursor", | |
| 29 | + | overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)}, | |
| 30 | + | want: cursorOut{valid: true, row: 2, col: 9, hasStyle: true}, | |
| 31 | + | }, | |
| 32 | + | { | |
| 33 | + | name: "copy search caret owns the cursor", | |
| 34 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1)}, | |
| 35 | + | want: cursorOut{valid: true, row: 2, col: 4, hasStyle: true}, | |
| 36 | + | }, | |
| 37 | + | { | |
| 38 | + | name: "notices above copy search hide the cursor", | |
| 39 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}}, | |
| 40 | + | want: cursorOut{hidden: true}, | |
| 41 | + | }, | |
| 42 | + | { | |
| 43 | + | name: "full input line hides the cursor", | |
| 44 | + | overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 10}}, | |
| 45 | + | want: cursorOut{hidden: true}, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | 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}}, | |
| 50 | + | want: cursorOut{hidden: true}, | |
| 51 | + | }, | |
| 52 | + | { | |
| 53 | + | name: "copy mode without a modal hides the cursor", | |
| 54 | + | overlays: capturedOverlayRenderState{copyActive: true}, | |
| 55 | + | want: cursorOut{hidden: true}, | |
| 56 | + | }, | |
| 57 | + | } { | |
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |
| 59 | + | require.Equal(t, tt.want, overlayInputCursor(tt.overlays, 20, 10)) | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // TestPaletteInputLineOwnsTerminalCursor drives the production render path: | |
| 65 | + | // the open palette shows the real terminal cursor after its query, which is | |
| 66 | + | // what lets client predictive echo type ahead in it. | |
| 67 | + | func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) { | |
| 68 | + | p, release := newBlockingPTY(t) | |
| 69 | + | defer release() | |
| 70 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 71 | + | client := vt.NewScreen(80, 25) | |
| 72 | + | d.paint(sess, ac, true, nil) | |
| 73 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |
| 74 | + | ||
| 75 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 76 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |
| 77 | + | require.True(t, client.CursorVisible(), "open palette shows the real cursor") | |
| 78 | + | row, col := client.CursorRow(), client.CursorCol() | |
| 79 | + | require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt") | |
| 80 | + | ||
| 81 | + | d.handleInput(sess, ac, []byte("ab")) | |
| 82 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |
| 83 | + | require.True(t, client.CursorVisible()) | |
| 84 | + | require.Equal(t, row, client.CursorRow()) | |
| 85 | + | require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query") | |
| 86 | + | require.Equal(t, 'b', client.Cell(col+1, row).Rune) | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 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 | + | for _, tt := range []struct { | |||
| 18 | + | name string | |||
| 19 | + | overlays capturedOverlayRenderState | |||
| 20 | + | want cursorOut | |||
| 21 | + | }{ | |||
| 22 | + | { | |||
| 23 | + | name: "palette caret owns the cursor", | |||
| 24 | + | overlays: capturedOverlayRenderState{palette: withCaret(4)}, | |||
| 25 | + | want: cursorOut{valid: true, row: 2, col: 7, hasStyle: true}, | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "prompt above palette owns the cursor", | |||
| 29 | + | overlays: capturedOverlayRenderState{palette: withCaret(4), prompt: withCaret(6)}, | |||
| 30 | + | want: cursorOut{valid: true, row: 2, col: 9, hasStyle: true}, | |||
| 31 | + | }, | |||
| 32 | + | { | |||
| 33 | + | name: "copy search caret owns the cursor", | |||
| 34 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1)}, | |||
| 35 | + | want: cursorOut{valid: true, row: 2, col: 4, hasStyle: true}, | |||
| 36 | + | }, | |||
| 37 | + | { | |||
| 38 | + | name: "notices above copy search hide the cursor", | |||
| 39 | + | overlays: capturedOverlayRenderState{copySearch: withCaret(1), noticesOverlay: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}}}, | |||
| 40 | + | want: cursorOut{hidden: true}, | |||
| 41 | + | }, | |||
| 42 | + | { | |||
| 43 | + | name: "full input line hides the cursor", | |||
| 44 | + | overlays: capturedOverlayRenderState{palette: capturedModal{active: true, presentation: ui.Presentation{Inner: inner}, caretCol: 10}}, | |||
| 45 | + | want: cursorOut{hidden: true}, | |||
| 46 | + | }, | |||
| 47 | + | { | |||
| 48 | + | 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}}, | |||
| 50 | + | want: cursorOut{hidden: true}, | |||
| 51 | + | }, | |||
| 52 | + | { | |||
| 53 | + | name: "copy mode without a modal hides the cursor", | |||
| 54 | + | overlays: capturedOverlayRenderState{copyActive: true}, | |||
| 55 | + | want: cursorOut{hidden: true}, | |||
| 56 | + | }, | |||
| 57 | + | } { | |||
| 58 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 59 | + | require.Equal(t, tt.want, overlayInputCursor(tt.overlays, 20, 10)) | |||
| 60 | + | }) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | // TestPaletteInputLineOwnsTerminalCursor drives the production render path: | |||
| 65 | + | // the open palette shows the real terminal cursor after its query, which is | |||
| 66 | + | // what lets client predictive echo type ahead in it. | |||
| 67 | + | func TestPaletteInputLineOwnsTerminalCursor(t *testing.T) { | |||
| 68 | + | p, release := newBlockingPTY(t) | |||
| 69 | + | defer release() | |||
| 70 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 71 | + | client := vt.NewScreen(80, 25) | |||
| 72 | + | d.paint(sess, ac, true, nil) | |||
| 73 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |||
| 74 | + | ||||
| 75 | + | d.handleInput(sess, ac, []byte("\x1b ")) | |||
| 76 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |||
| 77 | + | require.True(t, client.CursorVisible(), "open palette shows the real cursor") | |||
| 78 | + | row, col := client.CursorRow(), client.CursorCol() | |||
| 79 | + | require.Equal(t, '>', client.Cell(col-2, row).Rune, "cursor sits after the query prompt") | |||
| 80 | + | ||||
| 81 | + | d.handleInput(sess, ac, []byte("ab")) | |||
| 82 | + | mustApplyOutput(t, client, awaitFrame(t, sends, "Output")) | |||
| 83 | + | require.True(t, client.CursorVisible()) | |||
| 84 | + | require.Equal(t, row, client.CursorRow()) | |||
| 85 | + | require.Equal(t, col+2, client.CursorCol(), "cursor follows typed query") | |||
| 86 | + | require.Equal(t, 'b', client.Cell(col+1, row).Rune) | |||
| 87 | + | } | |||
M internal/usecase/daemon/render_capture.go +4 -0
| @@ -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 { | |
| @@ -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 { | ||
M internal/usecase/daemon/render_pipeline.go +27 -0
| @@ -247,6 +247,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 247 | 247 | cursorInputs := state.cursor | |
| 248 | 248 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | |
| 249 | 249 | cursor := desiredCapturedCursor(cursorInputs, contentY) | |
| 250 | + | if overlaysActive { | |
| 251 | + | cursor = overlayInputCursor(state.overlays, width, frameHeight) | |
| 252 | + | } | |
| 250 | 253 | // A plain copy viewport is painted on a clone or the spare viewport page, | |
| 251 | 254 | // never into baseFrame. Keep | |
| 252 | 255 | // that unadorned base reusable between wheel events. Modal/floating paths | |
| @@ -290,6 +293,27 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |||
| 290 | 293 | return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |
| 291 | 294 | } | |
| 292 | 295 | ||
| 296 | + | // overlayInputCursor places the terminal cursor on the input caret of the | |
| 297 | + | // topmost modal, in the same priority order composeCapturedOverlays paints. | |
| 298 | + | // 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 { | |
| 301 | + | for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} { | |
| 302 | + | if !modal.active { | |
| 303 | + | continue | |
| 304 | + | } | |
| 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 { | |
| 309 | + | break | |
| 310 | + | } | |
| 311 | + | // Style 0 restores the user's terminal default cursor shape. | |
| 312 | + | return cursorOut{valid: true, row: row, col: col, style: 0, 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,6 +367,7 @@ 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 | 372 | state.cursor.hiddenByOverlay = o.active() | |
| 346 | 373 | } | |
| @@ -247,6 +247,9 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 247 | cursorInputs := state.cursor | 247 | cursorInputs := state.cursor | ||
| 248 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | 248 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | ||
| 249 | cursor := desiredCapturedCursor(cursorInputs, contentY) | 249 | cursor := desiredCapturedCursor(cursorInputs, contentY) | ||
| 250 | + | if overlaysActive { | |||
| 251 | + | cursor = overlayInputCursor(state.overlays, width, frameHeight) | |||
| 252 | + | } | |||
| 250 | // A plain copy viewport is painted on a clone or the spare viewport page, | 253 | // A plain copy viewport is painted on a clone or the spare viewport page, | ||
| 251 | // never into baseFrame. Keep | 254 | // never into baseFrame. Keep | ||
| 252 | // that unadorned base reusable between wheel events. Modal/floating paths | 255 | // that unadorned base reusable between wheel events. Modal/floating paths | ||
| @@ -290,6 +293,27 @@ 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} | 293 | return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | ||
| 291 | } | 294 | } | ||
| 292 | 295 | ||||
| 296 | + | // overlayInputCursor places the terminal cursor on the input caret of the | |||
| 297 | + | // topmost modal, in the same priority order composeCapturedOverlays paints. | |||
| 298 | + | // 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 { | |||
| 301 | + | for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} { | |||
| 302 | + | if !modal.active { | |||
| 303 | + | continue | |||
| 304 | + | } | |||
| 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 { | |||
| 309 | + | break | |||
| 310 | + | } | |||
| 311 | + | // Style 0 restores the user's terminal default cursor shape. | |||
| 312 | + | return cursorOut{valid: true, row: row, col: col, style: 0, 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,6 +367,7 @@ 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() | 372 | state.cursor.hiddenByOverlay = o.active() | ||
| 346 | } | 373 | } | ||
M internal/usecase/palette/model.go +14 -2
| @@ -127,6 +127,18 @@ 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 frame inner.Cols wide. It | |
| 135 | + | // reports false when the query fills the line. | |
| 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()) | |
| 141 | + | } | |
| 130 | 142 | func (m *Model) Up() { | |
| 131 | 143 | if m == nil || len(m.matches) == 0 { | |
| 132 | 144 | return | |
| @@ -371,10 +383,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||
| 371 | 383 | } | |
| 372 | 384 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | |
| 373 | 385 | if m == nil { | |
| 374 | - | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | |
| 386 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base) | |
| 375 | 387 | return frame | |
| 376 | 388 | } | |
| 377 | - | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | |
| 389 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base) | |
| 378 | 390 | start, visible := 1, frame.Height-1 | |
| 379 | 391 | if visible <= 0 || len(m.matches) == 0 { | |
| 380 | 392 | return frame | |
| @@ -127,6 +127,18 @@ 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 frame inner.Cols wide. It | |||
| 135 | + | // reports false when the query fills the line. | |||
| 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()) | |||
| 141 | + | } | |||
| 130 | func (m *Model) Up() { | 142 | func (m *Model) Up() { | ||
| 131 | if m == nil || len(m.matches) == 0 { | 143 | if m == nil || len(m.matches) == 0 { | ||
| 132 | return | 144 | return | ||
| @@ -371,10 +383,10 @@ func (m *Model) Render(inner domain.Size, opts RenderOptions) renderer.Frame { | |||||
| 371 | } | 383 | } | ||
| 372 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 384 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | ||
| 373 | if m == nil { | 385 | if m == nil { | ||
| 374 | - | ui.DrawInputLine(frame, 0, "> ", "", base, selection) | 386 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, "", base) |
| 375 | return frame | 387 | return frame | ||
| 376 | } | 388 | } | ||
| 377 | - | ui.DrawInputLine(frame, 0, "> ", m.Query(), base, selection) | 389 | + | ui.DrawInputLine(frame, 0, paletteInputPrefix, m.Query(), base) |
| 378 | start, visible := 1, frame.Height-1 | 390 | start, visible := 1, frame.Height-1 | ||
| 379 | if visible <= 0 || len(m.matches) == 0 { | 391 | if visible <= 0 || len(m.matches) == 0 { | ||
| 380 | return frame | 392 | 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 +13 -1
| @@ -49,6 +49,18 @@ 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 frame inner.Cols wide. It | |
| 56 | + | // reports false when the value fills the line. | |
| 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()) | |
| 62 | + | } | |
| 63 | + | ||
| 52 | 64 | func (m *Model) Title() string { | |
| 53 | 65 | if m == nil { | |
| 54 | 66 | return "" | |
| @@ -79,7 +91,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||
| 79 | 91 | return frame | |
| 80 | 92 | } | |
| 81 | 93 | 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) | |
| 94 | + | ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base) | |
| 83 | 95 | if frame.Height > 1 && m != nil && m.errMsg != "" { | |
| 84 | 96 | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection) | |
| 85 | 97 | } | |
| @@ -49,6 +49,18 @@ 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 frame inner.Cols wide. It | |||
| 56 | + | // reports false when the value fills the line. | |||
| 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()) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 52 | func (m *Model) Title() string { | 64 | func (m *Model) Title() string { | ||
| 53 | if m == nil { | 65 | if m == nil { | ||
| 54 | return "" | 66 | return "" | ||
| @@ -79,7 +91,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||||
| 79 | return frame | 91 | return frame | ||
| 80 | } | 92 | } | ||
| 81 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: styles.Base}) | 93 | 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) | 94 | + | ui.DrawInputLine(frame, 0, promptInputPrefix, m.Value(), styles.Base) |
| 83 | if frame.Height > 1 && m != nil && m.errMsg != "" { | 95 | if frame.Height > 1 && m != nil && m.errMsg != "" { | ||
| 84 | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection) | 96 | ui.DrawText(frame, 0, 1, frame.Width, m.errMsg, styles.Selection) | ||
| 85 | } | 97 | } | ||
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 +15 -10
| @@ -20,18 +20,23 @@ func (t *TextInput) Value() string { return string(t.runes) } | |||
| 20 | 20 | ||
| 21 | 21 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | |
| 22 | 22 | ||
| 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) { | |
| 23 | + | // DrawInputLine draws prefix + value. The caret is not drawn: the daemon | |
| 24 | + | // places the real terminal cursor at InputCaret, so client predictive echo | |
| 25 | + | // can type ahead on the input line. | |
| 26 | + | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) { | |
| 25 | 27 | if y < 0 || y >= f.Height { | |
| 26 | 28 | return | |
| 27 | 29 | } | |
| 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}) | |
| 30 | + | DrawText(f, 0, y, f.Width, prefix+value, style) | |
| 31 | + | } | |
| 32 | + | ||
| 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) { | |
| 37 | + | col := 0 | |
| 38 | + | for _, r := range prefix + value { | |
| 39 | + | col += renderer.RuneWidth(r) | |
| 36 | 40 | } | |
| 41 | + | return col, col < width | |
| 37 | 42 | } | |
| @@ -20,18 +20,23 @@ func (t *TextInput) Value() string { return string(t.runes) } | |||||
| 20 | 20 | ||||
| 21 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | 21 | func (t *TextInput) SetValue(value string) { t.runes = []rune(value) } | ||
| 22 | 22 | ||||
| 23 | - | // DrawInputLine draws prefix + value followed by a reverse-video caret cell. | 23 | + | // 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) { | 24 | + | // places the real terminal cursor at InputCaret, so client predictive echo |
| 25 | + | // can type ahead on the input line. | |||
| 26 | + | func DrawInputLine(f renderer.Frame, y int, prefix, value string, style renderer.Style) { | |||
| 25 | if y < 0 || y >= f.Height { | 27 | if y < 0 || y >= f.Height { | ||
| 26 | return | 28 | return | ||
| 27 | } | 29 | } | ||
| 28 | - | x := DrawText(f, 0, y, f.Width, prefix+value, style) | 30 | + | DrawText(f, 0, y, f.Width, prefix+value, style) |
| 29 | - | if x >= 0 && x < f.Width { | 31 | + | } |
| 30 | - | caret := style | 32 | + | |
| 31 | - | caret.Inverse = true | 33 | + | // InputCaret is the caret column of an input line drawn by DrawInputLine in a |
| 32 | - | if len(caretStyle) > 0 { | 34 | + | // frame width columns wide. It reports false when prefix + value does not |
| 33 | - | caret = caretStyle[0] | 35 | + | // leave room for the caret, so a clipped line never shows a misplaced cursor. |
| 34 | - | } | 36 | + | func InputCaret(width int, prefix, value string) (int, bool) { |
| 35 | - | f.Set(x, y, renderer.Cell{Rune: ' ', Style: caret}) | 37 | + | col := 0 |
| 38 | + | for _, r := range prefix + value { | |||
| 39 | + | col += renderer.RuneWidth(r) | |||
| 36 | } | 40 | } | ||
| 41 | + | return col, col < width | |||
| 37 | } | 42 | } | ||
M internal/usecase/ui/textinput_test.go +22 -14
| @@ -22,7 +22,7 @@ func TestTextInputEditsValue(t *testing.T) { | |||
| 22 | 22 | require.Equal(t, "ship", input.Value()) | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | - | func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | |
| 25 | + | func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) { | |
| 26 | 26 | frame := renderer.NewFrame(6, 1) | |
| 27 | 27 | style := renderer.DefaultStyle() | |
| 28 | 28 | ||
| @@ -33,21 +33,29 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | |||
| 33 | 33 | require.Equal(t, 'a', frame.At(2, 0).Rune) | |
| 34 | 34 | require.Equal(t, 'b', frame.At(3, 0).Rune) | |
| 35 | 35 | 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) | |
| 36 | + | require.False(t, frame.At(5, 0).Style.Inverse, "the real terminal cursor is the caret") | |
| 38 | 37 | } | |
| 39 | 38 | ||
| 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") | |
| 39 | + | func TestInputCaret(t *testing.T) { | |
| 40 | + | for _, tt := range []struct { | |
| 41 | + | name string | |
| 42 | + | width int | |
| 43 | + | prefix, value string | |
| 44 | + | wantCol int | |
| 45 | + | wantOK bool | |
| 46 | + | }{ | |
| 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}, | |
| 52 | + | } { | |
| 53 | + | t.Run(tt.name, func(t *testing.T) { | |
| 54 | + | col, ok := InputCaret(tt.width, tt.prefix, tt.value) | |
| 55 | + | require.Equal(t, tt.wantCol, col) | |
| 56 | + | require.Equal(t, tt.wantOK, ok) | |
| 57 | + | }) | |
| 58 | + | } | |
| 51 | 59 | } | |
| 52 | 60 | ||
| 53 | 61 | func TestDrawInputLineIgnoresInvalidRow(t *testing.T) { | |
| @@ -22,7 +22,7 @@ func TestTextInputEditsValue(t *testing.T) { | |||||
| 22 | require.Equal(t, "ship", input.Value()) | 22 | require.Equal(t, "ship", input.Value()) | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | 25 | + | func TestDrawInputLineDrawsPrefixAndValueWithoutCaret(t *testing.T) { |
| 26 | frame := renderer.NewFrame(6, 1) | 26 | frame := renderer.NewFrame(6, 1) | ||
| 27 | style := renderer.DefaultStyle() | 27 | style := renderer.DefaultStyle() | ||
| 28 | 28 | ||||
| @@ -33,21 +33,29 @@ func TestDrawInputLineDrawsPrefixValueAndCaret(t *testing.T) { | |||||
| 33 | require.Equal(t, 'a', frame.At(2, 0).Rune) | 33 | require.Equal(t, 'a', frame.At(2, 0).Rune) | ||
| 34 | require.Equal(t, 'b', frame.At(3, 0).Rune) | 34 | require.Equal(t, 'b', frame.At(3, 0).Rune) | ||
| 35 | require.Equal(t, 'c', frame.At(4, 0).Rune) | 35 | require.Equal(t, 'c', frame.At(4, 0).Rune) | ||
| 36 | - | require.Equal(t, ' ', frame.At(5, 0).Rune) | 36 | + | 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 | } | 37 | } | ||
| 39 | 38 | ||||
| 40 | - | func TestDrawInputLineClipsCaret(t *testing.T) { | 39 | + | func TestInputCaret(t *testing.T) { |
| 41 | - | frame := renderer.NewFrame(4, 1) | 40 | + | for _, tt := range []struct { |
| 42 | - | style := renderer.DefaultStyle() | 41 | + | name string |
| 43 | - | 42 | + | width int | |
| 44 | - | DrawInputLine(frame, 0, "> ", "abcdef", style) | 43 | + | prefix, value string |
| 45 | - | 44 | + | wantCol int | |
| 46 | - | require.Equal(t, '>', frame.At(0, 0).Rune) | 45 | + | wantOK bool |
| 47 | - | require.Equal(t, ' ', frame.At(1, 0).Rune) | 46 | + | }{ |
| 48 | - | require.Equal(t, 'a', frame.At(2, 0).Rune) | 47 | + | {name: "after value", width: 6, prefix: "> ", value: "abc", wantCol: 5, wantOK: true}, |
| 49 | - | require.Equal(t, 'b', frame.At(3, 0).Rune) | 48 | + | {name: "empty value", width: 6, prefix: "/", wantCol: 1, wantOK: true}, |
| 50 | - | require.False(t, frame.At(3, 0).Style.Inverse, "caret beyond clip should not overwrite final value cell") | 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}, | |||
| 52 | + | } { | |||
| 53 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 54 | + | col, ok := InputCaret(tt.width, tt.prefix, tt.value) | |||
| 55 | + | require.Equal(t, tt.wantCol, col) | |||
| 56 | + | require.Equal(t, tt.wantOK, ok) | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 51 | } | 59 | } | ||
| 52 | 60 | ||||
| 53 | func TestDrawInputLineIgnoresInvalidRow(t *testing.T) { | 61 | func TestDrawInputLineIgnoresInvalidRow(t *testing.T) { | ||
M internal/usecase/visualsearch/model.go +13 -1
| @@ -70,6 +70,18 @@ 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 frame inner.Cols wide. It | |
| 77 | + | // reports false when the query fills the line. | |
| 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()) | |
| 83 | + | } | |
| 84 | + | ||
| 73 | 85 | func (m *Model) Up() { | |
| 74 | 86 | if m != nil && m.selected > 0 { | |
| 75 | 87 | m.selected-- | |
| @@ -180,7 +192,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||
| 180 | 192 | } | |
| 181 | 193 | base, selection := styles.Base, styles.Selection | |
| 182 | 194 | 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) | |
| 195 | + | ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base) | |
| 184 | 196 | visible := frame.Height - 1 | |
| 185 | 197 | if visible <= 0 || len(m.matches) == 0 { | |
| 186 | 198 | return frame | |
| @@ -70,6 +70,18 @@ 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 frame inner.Cols wide. It | |||
| 77 | + | // reports false when the query fills the line. | |||
| 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()) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 73 | func (m *Model) Up() { | 85 | func (m *Model) Up() { | ||
| 74 | if m != nil && m.selected > 0 { | 86 | if m != nil && m.selected > 0 { | ||
| 75 | m.selected-- | 87 | m.selected-- | ||
| @@ -180,7 +192,7 @@ func (m *Model) RenderStyled(inner domain.Size, styles RenderStyles) renderer.Fr | |||||
| 180 | } | 192 | } | ||
| 181 | base, selection := styles.Base, styles.Selection | 193 | base, selection := styles.Base, styles.Selection | ||
| 182 | ui.FillRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, renderer.Cell{Rune: ' ', Style: base}) | 194 | 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) | 195 | + | ui.DrawInputLine(frame, 0, searchInputPrefix, m.Query(), base) |
| 184 | visible := frame.Height - 1 | 196 | visible := frame.Height - 1 | ||
| 185 | if visible <= 0 || len(m.matches) == 0 { | 197 | if visible <= 0 || len(m.matches) == 0 { | ||
| 186 | return frame | 198 | 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")) | ||