feat(terminal): preserve resize content and add visual selection
- commit
- 23ebd52001932f37bceea57d3cacded7979e17fc
- author
- brice · · 2026-07-03 13:49 UTC
- committer
- brice · · 2026-07-03 14:03 UTC
- parent
- f618fc7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +628 -94
M README.md +6 -6
| @@ -62,30 +62,30 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se | |||
| 62 | 62 | | NXT | switch to next tab | | |
| 63 | 63 | | PVT | switch to previous tab | | |
| 64 | 64 | | SSP | switch session or tab | | |
| 65 | - | | CPY | enter scrollback mode | | |
| 65 | + | | VIS | enter visual mode | | |
| 66 | 66 | | RNS | rename session | | |
| 67 | 67 | | DET | detach | | |
| 68 | 68 | ||
| 69 | 69 | Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error. | |
| 70 | 70 | ||
| 71 | - | ## Scrollback and visual copy | |
| 71 | + | ## Visual mode | |
| 72 | 72 | ||
| 73 | - | Scrollback mode freezes a view over history while the program keeps running underneath. Mouse wheel up enters scrollback mode and shows `[SCROLL]`; scrolling back down to the live bottom exits. When a line selection is active, the selected rows are highlighted and the status bar shows `[VISUAL]`. | |
| 73 | + | Visual mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. The status bar shows `[VISUAL]`; when a line selection is active, selected rows are highlighted, the status bar shows `[SELECT]`, and a visible line cursor marks the active row. | |
| 74 | 74 | ||
| 75 | 75 | | Key | Action | | |
| 76 | 76 | |---|---| | |
| 77 | 77 | | j / k, Up / Down | move one line | | |
| 78 | 78 | | PgUp / PgDn | move one page | | |
| 79 | 79 | | g / G | jump to top / bottom | | |
| 80 | - | | Space | toggle visual line selection | | |
| 80 | + | | v or Space | toggle line selection | | |
| 81 | 81 | | y or Enter | copy selection and exit | | |
| 82 | 82 | | q, Esc, Ctrl-C | exit without copying | | |
| 83 | 83 | ||
| 84 | - | Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead. | |
| 84 | + | Drag with the mouse to select lines in visual mode. Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead. | |
| 85 | 85 | ||
| 86 | 86 | ## Mouse | |
| 87 | 87 | ||
| 88 | - | vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In scrollback mode, the wheel scrolls the scrollback view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child. | |
| 88 | + | vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In visual mode, the wheel scrolls the frozen view and left-button drag selects lines. On the normal screen, left-button drag enters visual mode and selects the dragged lines; a simple click is left alone. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child. | |
| 89 | 89 | ||
| 90 | 90 | vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state. | |
| 91 | 91 | ||
| @@ -62,30 +62,30 @@ The status bar marks ephemeral sessions with `*`, for example `0*`. Ephemeral se | |||||
| 62 | | NXT | switch to next tab | | 62 | | NXT | switch to next tab | | ||
| 63 | | PVT | switch to previous tab | | 63 | | PVT | switch to previous tab | | ||
| 64 | | SSP | switch session or tab | | 64 | | SSP | switch session or tab | | ||
| 65 | - | | CPY | enter scrollback mode | | 65 | + | | VIS | enter visual mode | |
| 66 | | RNS | rename session | | 66 | | RNS | rename session | | ||
| 67 | | DET | detach | | 67 | | DET | detach | | ||
| 68 | 68 | ||||
| 69 | Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error. | 69 | Rename prompts are prefilled with the current session name. Type text, use Backspace to edit, Enter to submit, or Esc/Ctrl-C to cancel. Empty names and names already used by another session are rejected; the prompt stays open and shows the validation error. | ||
| 70 | 70 | ||||
| 71 | - | ## Scrollback and visual copy | 71 | + | ## Visual mode |
| 72 | 72 | ||||
| 73 | - | Scrollback mode freezes a view over history while the program keeps running underneath. Mouse wheel up enters scrollback mode and shows `[SCROLL]`; scrolling back down to the live bottom exits. When a line selection is active, the selected rows are highlighted and the status bar shows `[VISUAL]`. | 73 | + | Visual mode freezes a view over history while the program keeps running underneath. Enter it with the `VIS` palette command or by scrolling up with the mouse wheel. The status bar shows `[VISUAL]`; when a line selection is active, selected rows are highlighted, the status bar shows `[SELECT]`, and a visible line cursor marks the active row. |
| 74 | 74 | ||||
| 75 | | Key | Action | | 75 | | Key | Action | | ||
| 76 | |---|---| | 76 | |---|---| | ||
| 77 | | j / k, Up / Down | move one line | | 77 | | j / k, Up / Down | move one line | | ||
| 78 | | PgUp / PgDn | move one page | | 78 | | PgUp / PgDn | move one page | | ||
| 79 | | g / G | jump to top / bottom | | 79 | | g / G | jump to top / bottom | | ||
| 80 | - | | Space | toggle visual line selection | | 80 | + | | v or Space | toggle line selection | |
| 81 | | y or Enter | copy selection and exit | | 81 | | y or Enter | copy selection and exit | | ||
| 82 | | q, Esc, Ctrl-C | exit without copying | | 82 | | q, Esc, Ctrl-C | exit without copying | | ||
| 83 | 83 | ||||
| 84 | - | Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead. | 84 | + | Drag with the mouse to select lines in visual mode. Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. After a successful copy, the right side of the status bar reports `copied N chars to clipboard` until the next screen update. If the selection exceeds the OSC 52 copy limit, the status bar reports `selection too large to copy` instead. |
| 85 | 85 | ||||
| 86 | ## Mouse | 86 | ## Mouse | ||
| 87 | 87 | ||||
| 88 | - | vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In scrollback mode, the wheel scrolls the scrollback view. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child. | 88 | + | vev enables terminal mouse reporting while attached. Mouse input on the status row is reserved for vev and is not sent to the child. In visual mode, the wheel scrolls the frozen view and left-button drag selects lines. On the normal screen, left-button drag enters visual mode and selects the dragged lines; a simple click is left alone. When the child enables mouse reporting, SGR mouse events are forwarded to it. Otherwise, wheel events in the alternate screen are translated to Up/Down arrow keys (`ESC[A` / `ESC[B`). This translation intentionally does not honor DECCKM application-cursor mode (`ESC OA` / `ESC OB`). An extremely rare input read split exactly between `ESC[` and `<` in an SGR mouse sequence may leak the partial `ESC[` to the child. |
| 89 | 89 | ||||
| 90 | vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state. | 90 | vev follows the child application's cursor visibility and style requests and appends a hardware cursor update after each paint. Visible cursors blink as requested by the terminal/application state. | ||
| 91 | 91 | ||||
M internal/usecase/command/command.go +1 -1
| @@ -7,7 +7,7 @@ type Context interface { | |||
| 7 | 7 | NextTab() error | |
| 8 | 8 | PrevTab() error | |
| 9 | 9 | Detach() error | |
| 10 | - | EnterCopyMode() error | |
| 10 | + | EnterVisualMode() error | |
| 11 | 11 | RenameSession() error | |
| 12 | 12 | OpenSessionPicker() error | |
| 13 | 13 | } | |
| @@ -7,7 +7,7 @@ type Context interface { | |||||
| 7 | NextTab() error | 7 | NextTab() error | ||
| 8 | PrevTab() error | 8 | PrevTab() error | ||
| 9 | Detach() error | 9 | Detach() error | ||
| 10 | - | EnterCopyMode() error | 10 | + | EnterVisualMode() error |
| 11 | RenameSession() error | 11 | RenameSession() error | ||
| 12 | OpenSessionPicker() error | 12 | OpenSessionPicker() error | ||
| 13 | } | 13 | } | ||
M internal/usecase/command/registry.go +1 -1
| @@ -10,7 +10,7 @@ func Registry() []Command { | |||
| 10 | 10 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | |
| 11 | 11 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | |
| 12 | 12 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 13 | - | {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }}, | |
| 13 | + | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 14 | 14 | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | |
| 15 | 15 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 16 | 16 | } | |
| @@ -10,7 +10,7 @@ func Registry() []Command { | |||||
| 10 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | 10 | {Code: "NXT", Name: "Next tab", Desc: "Switch to the next tab", Run: func(ctx Context) error { return ctx.NextTab() }}, | ||
| 11 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | 11 | {Code: "PVT", Name: "Previous tab", Desc: "Switch to the previous tab", Run: func(ctx Context) error { return ctx.PrevTab() }}, | ||
| 12 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | 12 | {Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | ||
| 13 | - | {Code: "CPY", Name: "Copy mode", Desc: "Enter copy mode", Run: func(ctx Context) error { return ctx.EnterCopyMode() }}, | 13 | + | {Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, |
| 14 | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | 14 | {Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }}, | ||
| 15 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | 15 | {Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | ||
| 16 | } | 16 | } | ||
M internal/usecase/command/registry_test.go +3 -3
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | |
| 10 | 10 | commands := Registry() | |
| 11 | - | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "CPY", "RNS", "DET"} | |
| 11 | + | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} | |
| 12 | 12 | ||
| 13 | 13 | if len(commands) != len(wantCodes) { | |
| 14 | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -56,7 +56,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 56 | 56 | {code: "NXT", want: "NextTab"}, | |
| 57 | 57 | {code: "PVT", want: "PrevTab"}, | |
| 58 | 58 | {code: "SSP", want: "OpenSessionPicker"}, | |
| 59 | - | {code: "CPY", want: "EnterCopyMode"}, | |
| 59 | + | {code: "VIS", want: "EnterVisualMode"}, | |
| 60 | 60 | {code: "RNS", want: "RenameSession"}, | |
| 61 | 61 | {code: "DET", want: "Detach"}, | |
| 62 | 62 | } | |
| @@ -100,6 +100,6 @@ func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | |||
| 100 | 100 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | |
| 101 | 101 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | |
| 102 | 102 | func (s *spyContext) Detach() error { return s.record("Detach") } | |
| 103 | - | func (s *spyContext) EnterCopyMode() error { return s.record("EnterCopyMode") } | |
| 103 | + | func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") } | |
| 104 | 104 | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | |
| 105 | 105 | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | |
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | 8 | ||||
| 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | 9 | func TestRegistryCodesAreUniqueUppercaseThreeLettersInOrder(t *testing.T) { | ||
| 10 | commands := Registry() | 10 | commands := Registry() | ||
| 11 | - | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "CPY", "RNS", "DET"} | 11 | + | wantCodes := []string{"CNT", "CLT", "NXT", "PVT", "SSP", "VIS", "RNS", "DET"} |
| 12 | 12 | ||||
| 13 | if len(commands) != len(wantCodes) { | 13 | if len(commands) != len(wantCodes) { | ||
| 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 14 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -56,7 +56,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 56 | {code: "NXT", want: "NextTab"}, | 56 | {code: "NXT", want: "NextTab"}, | ||
| 57 | {code: "PVT", want: "PrevTab"}, | 57 | {code: "PVT", want: "PrevTab"}, | ||
| 58 | {code: "SSP", want: "OpenSessionPicker"}, | 58 | {code: "SSP", want: "OpenSessionPicker"}, | ||
| 59 | - | {code: "CPY", want: "EnterCopyMode"}, | 59 | + | {code: "VIS", want: "EnterVisualMode"}, |
| 60 | {code: "RNS", want: "RenameSession"}, | 60 | {code: "RNS", want: "RenameSession"}, | ||
| 61 | {code: "DET", want: "Detach"}, | 61 | {code: "DET", want: "Detach"}, | ||
| 62 | } | 62 | } | ||
| @@ -100,6 +100,6 @@ func (s *spyContext) CloseTab() error { return s.record("CloseTab") } | |||||
| 100 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | 100 | func (s *spyContext) NextTab() error { return s.record("NextTab") } | ||
| 101 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | 101 | func (s *spyContext) PrevTab() error { return s.record("PrevTab") } | ||
| 102 | func (s *spyContext) Detach() error { return s.record("Detach") } | 102 | func (s *spyContext) Detach() error { return s.record("Detach") } | ||
| 103 | - | func (s *spyContext) EnterCopyMode() error { return s.record("EnterCopyMode") } | 103 | + | func (s *spyContext) EnterVisualMode() error { return s.record("EnterVisualMode") } |
| 104 | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | 104 | func (s *spyContext) RenameSession() error { return s.record("RenameSession") } | ||
| 105 | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | 105 | func (s *spyContext) OpenSessionPicker() error { return s.record("OpenSessionPicker") } | ||
M internal/usecase/copy/mode.go +27 -8
| @@ -55,10 +55,25 @@ func NewMode(s Snapshot) *Mode { | |||
| 55 | 55 | return m | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | func (m *Mode) Move(s Snapshot, delta int) { m.setCursor(s, m.Cursor+delta) } | |
| 59 | - | func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |
| 60 | - | func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) } | |
| 61 | - | func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) } | |
| 58 | + | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } | |
| 59 | + | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |
| 60 | + | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } | |
| 61 | + | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) } | |
| 62 | + | ||
| 63 | + | // SetCursor moves the visual cursor to row, clamping it to the snapshot and | |
| 64 | + | // scrolling the viewport just enough to keep it visible. | |
| 65 | + | func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) } | |
| 66 | + | ||
| 67 | + | // StartSelectionAt starts a line-wise visual selection at row. | |
| 68 | + | func (m *Mode) StartSelectionAt(s Snapshot, row int) { | |
| 69 | + | m.SetCursor(s, row) | |
| 70 | + | m.Anchor = m.Cursor | |
| 71 | + | m.Selecting = true | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // ExtendTo extends an active line-wise visual selection to row while preserving | |
| 75 | + | // the original anchor. | |
| 76 | + | func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) } | |
| 62 | 77 | ||
| 63 | 78 | func (m *Mode) AtBottom(s Snapshot) bool { | |
| 64 | 79 | total := len(s.Rows) | |
| @@ -137,11 +152,15 @@ func (m *Mode) Render(s Snapshot) renderer.Frame { | |||
| 137 | 152 | } | |
| 138 | 153 | row := frame.Row(y) | |
| 139 | 154 | copy(row, s.Rows[src]) | |
| 140 | - | if selected && src >= lo && src <= hi { | |
| 155 | + | lineSelected := selected && src >= lo && src <= hi | |
| 156 | + | if lineSelected { | |
| 141 | 157 | for x := range row { | |
| 142 | - | row[x].Style.Inverse = !row[x].Style.Inverse | |
| 158 | + | row[x].Style.Inverse = true | |
| 143 | 159 | } | |
| 144 | 160 | } | |
| 161 | + | if src == m.Cursor && !lineSelected && len(row) > 0 { | |
| 162 | + | row[0].Style.Inverse = true | |
| 163 | + | } | |
| 145 | 164 | } | |
| 146 | 165 | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows)) | |
| 147 | 166 | return frame | |
| @@ -166,9 +185,9 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |||
| 166 | 185 | for i := range row { | |
| 167 | 186 | row[i] = renderer.BlankCell() | |
| 168 | 187 | } | |
| 169 | - | text := " [SCROLL] " | |
| 188 | + | text := " [VISUAL] " | |
| 170 | 189 | if m.Selecting { | |
| 171 | - | text = " [VISUAL] " | |
| 190 | + | text = " [SELECT] " | |
| 172 | 191 | } | |
| 173 | 192 | if total > 0 { | |
| 174 | 193 | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | |
| @@ -55,10 +55,25 @@ func NewMode(s Snapshot) *Mode { | |||||
| 55 | return m | 55 | return m | ||
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | - | func (m *Mode) Move(s Snapshot, delta int) { m.setCursor(s, m.Cursor+delta) } | 58 | + | func (m *Mode) Move(s Snapshot, delta int) { m.SetCursor(s, m.Cursor+delta) } |
| 59 | - | func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s.Height, 1)) } | 59 | + | func (m *Mode) Page(s Snapshot, pages int) { m.SetCursor(s, m.Cursor+pages*max(s.Height, 1)) } |
| 60 | - | func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) } | 60 | + | func (m *Mode) Top(s Snapshot) { m.SetCursor(s, 0) } |
| 61 | - | func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) } | 61 | + | func (m *Mode) Bottom(s Snapshot) { m.SetCursor(s, len(s.Rows)-1) } |
| 62 | + | ||||
| 63 | + | // SetCursor moves the visual cursor to row, clamping it to the snapshot and | |||
| 64 | + | // scrolling the viewport just enough to keep it visible. | |||
| 65 | + | func (m *Mode) SetCursor(s Snapshot, row int) { m.setCursor(s, row) } | |||
| 66 | + | ||||
| 67 | + | // StartSelectionAt starts a line-wise visual selection at row. | |||
| 68 | + | func (m *Mode) StartSelectionAt(s Snapshot, row int) { | |||
| 69 | + | m.SetCursor(s, row) | |||
| 70 | + | m.Anchor = m.Cursor | |||
| 71 | + | m.Selecting = true | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // ExtendTo extends an active line-wise visual selection to row while preserving | |||
| 75 | + | // the original anchor. | |||
| 76 | + | func (m *Mode) ExtendTo(s Snapshot, row int) { m.SetCursor(s, row) } | |||
| 62 | 77 | ||||
| 63 | func (m *Mode) AtBottom(s Snapshot) bool { | 78 | func (m *Mode) AtBottom(s Snapshot) bool { | ||
| 64 | total := len(s.Rows) | 79 | total := len(s.Rows) | ||
| @@ -137,11 +152,15 @@ func (m *Mode) Render(s Snapshot) renderer.Frame { | |||||
| 137 | } | 152 | } | ||
| 138 | row := frame.Row(y) | 153 | row := frame.Row(y) | ||
| 139 | copy(row, s.Rows[src]) | 154 | copy(row, s.Rows[src]) | ||
| 140 | - | if selected && src >= lo && src <= hi { | 155 | + | lineSelected := selected && src >= lo && src <= hi |
| 156 | + | if lineSelected { | |||
| 141 | for x := range row { | 157 | for x := range row { | ||
| 142 | - | row[x].Style.Inverse = !row[x].Style.Inverse | 158 | + | row[x].Style.Inverse = true |
| 143 | } | 159 | } | ||
| 144 | } | 160 | } | ||
| 161 | + | if src == m.Cursor && !lineSelected && len(row) > 0 { | |||
| 162 | + | row[0].Style.Inverse = true | |||
| 163 | + | } | |||
| 145 | } | 164 | } | ||
| 146 | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows)) | 165 | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows)) | ||
| 147 | return frame | 166 | return frame | ||
| @@ -166,9 +185,9 @@ func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |||||
| 166 | for i := range row { | 185 | for i := range row { | ||
| 167 | row[i] = renderer.BlankCell() | 186 | row[i] = renderer.BlankCell() | ||
| 168 | } | 187 | } | ||
| 169 | - | text := " [SCROLL] " | 188 | + | text := " [VISUAL] " |
| 170 | if m.Selecting { | 189 | if m.Selecting { | ||
| 171 | - | text = " [VISUAL] " | 190 | + | text = " [SELECT] " |
| 172 | } | 191 | } | ||
| 173 | if total > 0 { | 192 | if total > 0 { | ||
| 174 | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | 193 | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | ||
M internal/usecase/copy/mode_test.go +110 -6
| @@ -16,6 +16,14 @@ func snapshot(lines []string, height int) Snapshot { | |||
| 16 | 16 | return Snapshot{Rows: rows, Width: 16, Height: height} | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | + | func inverseRow(text string) []renderer.Cell { | |
| 20 | + | cells := row(text) | |
| 21 | + | for i := range cells { | |
| 22 | + | cells[i].Style.Inverse = true | |
| 23 | + | } | |
| 24 | + | return cells | |
| 25 | + | } | |
| 26 | + | ||
| 19 | 27 | func TestCopyModeNavigationBounds(t *testing.T) { | |
| 20 | 28 | tests := []struct { | |
| 21 | 29 | name string | |
| @@ -129,24 +137,94 @@ func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) { | |||
| 129 | 137 | } | |
| 130 | 138 | } | |
| 131 | 139 | ||
| 140 | + | func TestCopyModeSelectionAPI(t *testing.T) { | |
| 141 | + | tests := []struct { | |
| 142 | + | name string | |
| 143 | + | ops func(*Mode, Snapshot) | |
| 144 | + | wantCursor int | |
| 145 | + | wantTop int | |
| 146 | + | wantAnchor int | |
| 147 | + | wantSelecting bool | |
| 148 | + | }{ | |
| 149 | + | { | |
| 150 | + | name: "SetCursor clamps and scrolls viewport", | |
| 151 | + | ops: func(m *Mode, s Snapshot) { | |
| 152 | + | m.SetCursor(s, -10) | |
| 153 | + | }, | |
| 154 | + | wantCursor: 0, | |
| 155 | + | wantTop: 0, | |
| 156 | + | wantAnchor: -1, | |
| 157 | + | }, | |
| 158 | + | { | |
| 159 | + | name: "StartSelectionAt sets cursor anchor and selecting", | |
| 160 | + | ops: func(m *Mode, s Snapshot) { | |
| 161 | + | m.StartSelectionAt(s, 2) | |
| 162 | + | }, | |
| 163 | + | wantCursor: 2, | |
| 164 | + | wantTop: 2, | |
| 165 | + | wantAnchor: 2, | |
| 166 | + | wantSelecting: true, | |
| 167 | + | }, | |
| 168 | + | { | |
| 169 | + | name: "ExtendTo preserves anchor while moving cursor", | |
| 170 | + | ops: func(m *Mode, s Snapshot) { | |
| 171 | + | m.StartSelectionAt(s, 1) | |
| 172 | + | m.ExtendTo(s, 4) | |
| 173 | + | }, | |
| 174 | + | wantCursor: 4, | |
| 175 | + | wantTop: 2, | |
| 176 | + | wantAnchor: 1, | |
| 177 | + | wantSelecting: true, | |
| 178 | + | }, | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | for _, tt := range tests { | |
| 182 | + | t.Run(tt.name, func(t *testing.T) { | |
| 183 | + | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |
| 184 | + | m := NewMode(s) | |
| 185 | + | tt.ops(m, s) | |
| 186 | + | if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop || m.Anchor != tt.wantAnchor || m.Selecting != tt.wantSelecting { | |
| 187 | + | t.Fatalf("mode = cursor:%d top:%d anchor:%d selecting:%v, want cursor:%d top:%d anchor:%d selecting:%v", m.Cursor, m.ViewportTop, m.Anchor, m.Selecting, tt.wantCursor, tt.wantTop, tt.wantAnchor, tt.wantSelecting) | |
| 188 | + | } | |
| 189 | + | }) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func TestCopyModeExtendToAboveAnchorSwapsSelectedBounds(t *testing.T) { | |
| 194 | + | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |
| 195 | + | m := NewMode(s) | |
| 196 | + | ||
| 197 | + | m.StartSelectionAt(s, 4) | |
| 198 | + | m.ExtendTo(s, 1) | |
| 199 | + | ||
| 200 | + | if m.Cursor != 1 || m.ViewportTop != 1 || m.Anchor != 4 || !m.Selecting { | |
| 201 | + | t.Fatalf("mode = cursor:%d top:%d anchor:%d selecting:%v, want cursor:1 top:1 anchor:4 selecting:true", m.Cursor, m.ViewportTop, m.Anchor, m.Selecting) | |
| 202 | + | } | |
| 203 | + | lo, hi, ok := m.SelectedBounds() | |
| 204 | + | if !ok || lo != 1 || hi != 4 { | |
| 205 | + | t.Fatalf("SelectedBounds = (%d,%d,%v), want (1,4,true)", lo, hi, ok) | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 132 | 209 | func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | |
| 133 | 210 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |
| 134 | 211 | m := NewMode(s) | |
| 135 | 212 | ||
| 136 | 213 | frame := m.Render(s) | |
| 137 | - | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") || strings.Contains(got, "[VISUAL]") { | |
| 138 | - | t.Fatalf("passive status = %q, want [SCROLL] and no copy/visual label", got) | |
| 214 | + | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") { | |
| 215 | + | t.Fatalf("passive status = %q, want [VISUAL] with N/M and no scroll/select label", got) | |
| 139 | 216 | } | |
| 140 | 217 | ||
| 141 | 218 | m.ToggleSelection() | |
| 142 | 219 | frame = m.Render(s) | |
| 143 | - | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") { | |
| 144 | - | t.Fatalf("selection status = %q, want [VISUAL] and no [SCROLL]", got) | |
| 220 | + | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") { | |
| 221 | + | t.Fatalf("selection status = %q, want [SELECT] with N/M and no [VISUAL]", got) | |
| 145 | 222 | } | |
| 146 | 223 | } | |
| 147 | 224 | ||
| 148 | 225 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |
| 149 | 226 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |
| 227 | + | s.Rows[1] = inverseRow("beta ") | |
| 150 | 228 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 151 | 229 | m.Move(s, 1) | |
| 152 | 230 | ||
| @@ -155,8 +233,34 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |||
| 155 | 233 | } | |
| 156 | 234 | frame := m.Render(s) | |
| 157 | 235 | for y := range 2 { | |
| 158 | - | if !frame.At(0, y).Style.Inverse { | |
| 159 | - | t.Fatalf("row %d first cell not inverse", y) | |
| 236 | + | for x, c := range frame.Row(y) { | |
| 237 | + | if !c.Style.Inverse { | |
| 238 | + | t.Fatalf("row %d cell %d not inverse", y, x) | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func TestCopyModeRenderLineCursorMarker(t *testing.T) { | |
| 245 | + | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 3) | |
| 246 | + | m := &Mode{ViewportTop: 0, Cursor: 1, Anchor: -1} | |
| 247 | + | ||
| 248 | + | frame := m.Render(s) | |
| 249 | + | if !frame.At(0, 1).Style.Inverse { | |
| 250 | + | t.Fatalf("cursor line first cell inverse = false, want true") | |
| 251 | + | } | |
| 252 | + | if frame.At(1, 1).Style.Inverse { | |
| 253 | + | t.Fatalf("cursor line second cell inverse = true, want marker on first cell only") | |
| 254 | + | } | |
| 255 | + | if frame.At(0, 0).Style.Inverse || frame.At(0, 2).Style.Inverse { | |
| 256 | + | t.Fatalf("non-cursor lines have inverse marker, want none") | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | m.StartSelectionAt(s, 1) | |
| 260 | + | frame = m.Render(s) | |
| 261 | + | for x, c := range frame.Row(1) { | |
| 262 | + | if !c.Style.Inverse { | |
| 263 | + | t.Fatalf("selected cursor row cell %d inverse = false, want full-line selection highlight", x) | |
| 160 | 264 | } | |
| 161 | 265 | } | |
| 162 | 266 | } | |
| @@ -16,6 +16,14 @@ func snapshot(lines []string, height int) Snapshot { | |||||
| 16 | return Snapshot{Rows: rows, Width: 16, Height: height} | 16 | return Snapshot{Rows: rows, Width: 16, Height: height} | ||
| 17 | } | 17 | } | ||
| 18 | 18 | ||||
| 19 | + | func inverseRow(text string) []renderer.Cell { | |||
| 20 | + | cells := row(text) | |||
| 21 | + | for i := range cells { | |||
| 22 | + | cells[i].Style.Inverse = true | |||
| 23 | + | } | |||
| 24 | + | return cells | |||
| 25 | + | } | |||
| 26 | + | ||||
| 19 | func TestCopyModeNavigationBounds(t *testing.T) { | 27 | func TestCopyModeNavigationBounds(t *testing.T) { | ||
| 20 | tests := []struct { | 28 | tests := []struct { | ||
| 21 | name string | 29 | name string | ||
| @@ -129,24 +137,94 @@ func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) { | |||||
| 129 | } | 137 | } | ||
| 130 | } | 138 | } | ||
| 131 | 139 | ||||
| 140 | + | func TestCopyModeSelectionAPI(t *testing.T) { | |||
| 141 | + | tests := []struct { | |||
| 142 | + | name string | |||
| 143 | + | ops func(*Mode, Snapshot) | |||
| 144 | + | wantCursor int | |||
| 145 | + | wantTop int | |||
| 146 | + | wantAnchor int | |||
| 147 | + | wantSelecting bool | |||
| 148 | + | }{ | |||
| 149 | + | { | |||
| 150 | + | name: "SetCursor clamps and scrolls viewport", | |||
| 151 | + | ops: func(m *Mode, s Snapshot) { | |||
| 152 | + | m.SetCursor(s, -10) | |||
| 153 | + | }, | |||
| 154 | + | wantCursor: 0, | |||
| 155 | + | wantTop: 0, | |||
| 156 | + | wantAnchor: -1, | |||
| 157 | + | }, | |||
| 158 | + | { | |||
| 159 | + | name: "StartSelectionAt sets cursor anchor and selecting", | |||
| 160 | + | ops: func(m *Mode, s Snapshot) { | |||
| 161 | + | m.StartSelectionAt(s, 2) | |||
| 162 | + | }, | |||
| 163 | + | wantCursor: 2, | |||
| 164 | + | wantTop: 2, | |||
| 165 | + | wantAnchor: 2, | |||
| 166 | + | wantSelecting: true, | |||
| 167 | + | }, | |||
| 168 | + | { | |||
| 169 | + | name: "ExtendTo preserves anchor while moving cursor", | |||
| 170 | + | ops: func(m *Mode, s Snapshot) { | |||
| 171 | + | m.StartSelectionAt(s, 1) | |||
| 172 | + | m.ExtendTo(s, 4) | |||
| 173 | + | }, | |||
| 174 | + | wantCursor: 4, | |||
| 175 | + | wantTop: 2, | |||
| 176 | + | wantAnchor: 1, | |||
| 177 | + | wantSelecting: true, | |||
| 178 | + | }, | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | for _, tt := range tests { | |||
| 182 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 183 | + | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |||
| 184 | + | m := NewMode(s) | |||
| 185 | + | tt.ops(m, s) | |||
| 186 | + | if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop || m.Anchor != tt.wantAnchor || m.Selecting != tt.wantSelecting { | |||
| 187 | + | t.Fatalf("mode = cursor:%d top:%d anchor:%d selecting:%v, want cursor:%d top:%d anchor:%d selecting:%v", m.Cursor, m.ViewportTop, m.Anchor, m.Selecting, tt.wantCursor, tt.wantTop, tt.wantAnchor, tt.wantSelecting) | |||
| 188 | + | } | |||
| 189 | + | }) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func TestCopyModeExtendToAboveAnchorSwapsSelectedBounds(t *testing.T) { | |||
| 194 | + | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |||
| 195 | + | m := NewMode(s) | |||
| 196 | + | ||||
| 197 | + | m.StartSelectionAt(s, 4) | |||
| 198 | + | m.ExtendTo(s, 1) | |||
| 199 | + | ||||
| 200 | + | if m.Cursor != 1 || m.ViewportTop != 1 || m.Anchor != 4 || !m.Selecting { | |||
| 201 | + | t.Fatalf("mode = cursor:%d top:%d anchor:%d selecting:%v, want cursor:1 top:1 anchor:4 selecting:true", m.Cursor, m.ViewportTop, m.Anchor, m.Selecting) | |||
| 202 | + | } | |||
| 203 | + | lo, hi, ok := m.SelectedBounds() | |||
| 204 | + | if !ok || lo != 1 || hi != 4 { | |||
| 205 | + | t.Fatalf("SelectedBounds = (%d,%d,%v), want (1,4,true)", lo, hi, ok) | |||
| 206 | + | } | |||
| 207 | + | } | |||
| 208 | + | ||||
| 132 | func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | 209 | func TestScrollbackModeStatusDistinguishesPassiveAndVisual(t *testing.T) { | ||
| 133 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | 210 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | ||
| 134 | m := NewMode(s) | 211 | m := NewMode(s) | ||
| 135 | 212 | ||||
| 136 | frame := m.Render(s) | 213 | frame := m.Render(s) | ||
| 137 | - | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") || strings.Contains(got, "[VISUAL]") { | 214 | + | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[SELECT]") { |
| 138 | - | t.Fatalf("passive status = %q, want [SCROLL] and no copy/visual label", got) | 215 | + | t.Fatalf("passive status = %q, want [VISUAL] with N/M and no scroll/select label", got) |
| 139 | } | 216 | } | ||
| 140 | 217 | ||||
| 141 | m.ToggleSelection() | 218 | m.ToggleSelection() | ||
| 142 | frame = m.Render(s) | 219 | frame = m.Render(s) | ||
| 143 | - | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") { | 220 | + | if got := frameText(frame.Row(s.Height)); !strings.Contains(got, "[SELECT]") || !strings.Contains(got, "3/3") || strings.Contains(got, "[VISUAL]") { |
| 144 | - | t.Fatalf("selection status = %q, want [VISUAL] and no [SCROLL]", got) | 221 | + | t.Fatalf("selection status = %q, want [SELECT] with N/M and no [VISUAL]", got) |
| 145 | } | 222 | } | ||
| 146 | } | 223 | } | ||
| 147 | 224 | ||||
| 148 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | 225 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | ||
| 149 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | 226 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | ||
| 227 | + | s.Rows[1] = inverseRow("beta ") | |||
| 150 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | 228 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | ||
| 151 | m.Move(s, 1) | 229 | m.Move(s, 1) | ||
| 152 | 230 | ||||
| @@ -155,8 +233,34 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |||||
| 155 | } | 233 | } | ||
| 156 | frame := m.Render(s) | 234 | frame := m.Render(s) | ||
| 157 | for y := range 2 { | 235 | for y := range 2 { | ||
| 158 | - | if !frame.At(0, y).Style.Inverse { | 236 | + | for x, c := range frame.Row(y) { |
| 159 | - | t.Fatalf("row %d first cell not inverse", y) | 237 | + | if !c.Style.Inverse { |
| 238 | + | t.Fatalf("row %d cell %d not inverse", y, x) | |||
| 239 | + | } | |||
| 240 | + | } | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func TestCopyModeRenderLineCursorMarker(t *testing.T) { | |||
| 245 | + | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 3) | |||
| 246 | + | m := &Mode{ViewportTop: 0, Cursor: 1, Anchor: -1} | |||
| 247 | + | ||||
| 248 | + | frame := m.Render(s) | |||
| 249 | + | if !frame.At(0, 1).Style.Inverse { | |||
| 250 | + | t.Fatalf("cursor line first cell inverse = false, want true") | |||
| 251 | + | } | |||
| 252 | + | if frame.At(1, 1).Style.Inverse { | |||
| 253 | + | t.Fatalf("cursor line second cell inverse = true, want marker on first cell only") | |||
| 254 | + | } | |||
| 255 | + | if frame.At(0, 0).Style.Inverse || frame.At(0, 2).Style.Inverse { | |||
| 256 | + | t.Fatalf("non-cursor lines have inverse marker, want none") | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | m.StartSelectionAt(s, 1) | |||
| 260 | + | frame = m.Render(s) | |||
| 261 | + | for x, c := range frame.Row(1) { | |||
| 262 | + | if !c.Style.Inverse { | |||
| 263 | + | t.Fatalf("selected cursor row cell %d inverse = false, want full-line selection highlight", x) | |||
| 160 | } | 264 | } | ||
| 161 | } | 265 | } | ||
| 162 | } | 266 | } | ||
M internal/usecase/daemon/client.go +31 -25
| @@ -42,31 +42,37 @@ import ( | |||
| 42 | 42 | ) | |
| 43 | 43 | ||
| 44 | 44 | type attachedClient struct { | |
| 45 | - | tr ports.Transport | |
| 46 | - | rend *renderer.Renderer | |
| 47 | - | size domain.Size | |
| 48 | - | keys *keys.Router | |
| 49 | - | sess Guarded[*session] | |
| 50 | - | copyMu sync.Mutex | |
| 51 | - | copyMode *scopy.Mode | |
| 52 | - | copyPending []byte | |
| 53 | - | copyESC pendingByteTimer | |
| 54 | - | copyFeedback string | |
| 55 | - | pickerMu sync.Mutex | |
| 56 | - | picker *picker.Model | |
| 57 | - | pickerPreview *tab | |
| 58 | - | pickerPending []byte | |
| 59 | - | pickerESC pendingByteTimer | |
| 60 | - | paletteMu sync.Mutex | |
| 61 | - | palette *palette.Model | |
| 62 | - | palettePending []byte | |
| 63 | - | promptMu sync.Mutex | |
| 64 | - | prompt *promptui.Model | |
| 65 | - | promptSubmit func(string) error | |
| 66 | - | promptPending []byte | |
| 67 | - | mouseScan mouse.Scanner | |
| 68 | - | lastCursor cursorOut | |
| 69 | - | sendMu sync.Mutex | |
| 45 | + | tr ports.Transport | |
| 46 | + | rend *renderer.Renderer | |
| 47 | + | size domain.Size | |
| 48 | + | keys *keys.Router | |
| 49 | + | sess Guarded[*session] | |
| 50 | + | copyMu sync.Mutex | |
| 51 | + | copyMode *scopy.Mode | |
| 52 | + | copyPending []byte | |
| 53 | + | copyESC pendingByteTimer | |
| 54 | + | copyFeedback string | |
| 55 | + | copyPressRow int | |
| 56 | + | copyPressRowValid bool | |
| 57 | + | copyDragging bool | |
| 58 | + | normalMousePressRow int | |
| 59 | + | normalMousePressTop int | |
| 60 | + | normalMousePressValid bool | |
| 61 | + | pickerMu sync.Mutex | |
| 62 | + | picker *picker.Model | |
| 63 | + | pickerPreview *tab | |
| 64 | + | pickerPending []byte | |
| 65 | + | pickerESC pendingByteTimer | |
| 66 | + | paletteMu sync.Mutex | |
| 67 | + | palette *palette.Model | |
| 68 | + | palettePending []byte | |
| 69 | + | promptMu sync.Mutex | |
| 70 | + | prompt *promptui.Model | |
| 71 | + | promptSubmit func(string) error | |
| 72 | + | promptPending []byte | |
| 73 | + | mouseScan mouse.Scanner | |
| 74 | + | lastCursor cursorOut | |
| 75 | + | sendMu sync.Mutex | |
| 70 | 76 | } | |
| 71 | 77 | ||
| 72 | 78 | type cursorOut struct { | |
| @@ -42,31 +42,37 @@ import ( | |||||
| 42 | ) | 42 | ) | ||
| 43 | 43 | ||||
| 44 | type attachedClient struct { | 44 | type attachedClient struct { | ||
| 45 | - | tr ports.Transport | 45 | + | tr ports.Transport |
| 46 | - | rend *renderer.Renderer | 46 | + | rend *renderer.Renderer |
| 47 | - | size domain.Size | 47 | + | size domain.Size |
| 48 | - | keys *keys.Router | 48 | + | keys *keys.Router |
| 49 | - | sess Guarded[*session] | 49 | + | sess Guarded[*session] |
| 50 | - | copyMu sync.Mutex | 50 | + | copyMu sync.Mutex |
| 51 | - | copyMode *scopy.Mode | 51 | + | copyMode *scopy.Mode |
| 52 | - | copyPending []byte | 52 | + | copyPending []byte |
| 53 | - | copyESC pendingByteTimer | 53 | + | copyESC pendingByteTimer |
| 54 | - | copyFeedback string | 54 | + | copyFeedback string |
| 55 | - | pickerMu sync.Mutex | 55 | + | copyPressRow int |
| 56 | - | picker *picker.Model | 56 | + | copyPressRowValid bool |
| 57 | - | pickerPreview *tab | 57 | + | copyDragging bool |
| 58 | - | pickerPending []byte | 58 | + | normalMousePressRow int |
| 59 | - | pickerESC pendingByteTimer | 59 | + | normalMousePressTop int |
| 60 | - | paletteMu sync.Mutex | 60 | + | normalMousePressValid bool |
| 61 | - | palette *palette.Model | 61 | + | pickerMu sync.Mutex |
| 62 | - | palettePending []byte | 62 | + | picker *picker.Model |
| 63 | - | promptMu sync.Mutex | 63 | + | pickerPreview *tab |
| 64 | - | prompt *promptui.Model | 64 | + | pickerPending []byte |
| 65 | - | promptSubmit func(string) error | 65 | + | pickerESC pendingByteTimer |
| 66 | - | promptPending []byte | 66 | + | paletteMu sync.Mutex |
| 67 | - | mouseScan mouse.Scanner | 67 | + | palette *palette.Model |
| 68 | - | lastCursor cursorOut | 68 | + | palettePending []byte |
| 69 | - | sendMu sync.Mutex | 69 | + | promptMu sync.Mutex |
| 70 | + | prompt *promptui.Model | |||
| 71 | + | promptSubmit func(string) error | |||
| 72 | + | promptPending []byte | |||
| 73 | + | mouseScan mouse.Scanner | |||
| 74 | + | lastCursor cursorOut | |||
| 75 | + | sendMu sync.Mutex | |||
| 70 | } | 76 | } | ||
| 71 | 77 | ||||
| 72 | type cursorOut struct { | 78 | type cursorOut struct { | ||
M internal/usecase/daemon/copymode.go +56 -1
| @@ -31,6 +31,7 @@ import ( | |||
| 31 | 31 | "github.com/bnema/vev/internal/ports" | |
| 32 | 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 33 | 33 | "github.com/bnema/vev/internal/usecase/keys" | |
| 34 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 34 | 35 | "github.com/bnema/vev/pkg/renderer" | |
| 35 | 36 | ) | |
| 36 | 37 | ||
| @@ -76,10 +77,64 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 76 | 77 | tb.mu.Unlock() | |
| 77 | 78 | ac.copyMu.Lock() | |
| 78 | 79 | ac.copyMode = scopy.NewMode(snap) | |
| 80 | + | ac.copyPressRowValid = false | |
| 81 | + | ac.copyDragging = false | |
| 82 | + | ac.normalMousePressValid = false | |
| 79 | 83 | ac.copyMu.Unlock() | |
| 80 | 84 | d.paint(sess, ac, true) | |
| 81 | 85 | } | |
| 82 | 86 | ||
| 87 | + | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 88 | + | if ev.Button != mouse.Left { | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | tb := sess.activeTab() | |
| 92 | + | if tb == nil { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | tb.mu.Lock() | |
| 97 | + | if ev.Row >= tb.screen.Frame.Height { | |
| 98 | + | tb.mu.Unlock() | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | ac.copyMu.Lock() | |
| 102 | + | if ac.copyMode == nil { | |
| 103 | + | ac.copyMu.Unlock() | |
| 104 | + | tb.mu.Unlock() | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 108 | + | absRow := ac.copyMode.ViewportTop + ev.Row | |
| 109 | + | changed := false | |
| 110 | + | switch ev.Type { | |
| 111 | + | case mouse.Press: | |
| 112 | + | ac.copyMode.SetCursor(snap, absRow) | |
| 113 | + | ac.copyPressRow = ac.copyMode.Cursor | |
| 114 | + | ac.copyPressRowValid = true | |
| 115 | + | ac.copyDragging = false | |
| 116 | + | changed = true | |
| 117 | + | case mouse.Motion: | |
| 118 | + | if !ac.copyPressRowValid { | |
| 119 | + | break | |
| 120 | + | } | |
| 121 | + | if !ac.copyDragging { | |
| 122 | + | ac.copyMode.StartSelectionAt(snap, ac.copyPressRow) | |
| 123 | + | ac.copyDragging = true | |
| 124 | + | } | |
| 125 | + | ac.copyMode.ExtendTo(snap, absRow) | |
| 126 | + | changed = true | |
| 127 | + | case mouse.Release: | |
| 128 | + | // Button release intentionally has no visual effect. | |
| 129 | + | } | |
| 130 | + | ac.copyMu.Unlock() | |
| 131 | + | tb.mu.Unlock() | |
| 132 | + | ||
| 133 | + | if changed { | |
| 134 | + | d.paint(sess, ac, true) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 83 | 138 | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |
| 84 | 139 | sess := ac.currentSession() | |
| 85 | 140 | if sess == nil { | |
| @@ -125,7 +180,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 125 | 180 | case 'G': | |
| 126 | 181 | ac.copyMode.Bottom(snap) | |
| 127 | 182 | changed = true | |
| 128 | - | case ' ': | |
| 183 | + | case ' ', 'v': | |
| 129 | 184 | ac.copyMode.ToggleSelection() | |
| 130 | 185 | changed = true | |
| 131 | 186 | case '\r', '\n', 'y': | |
| @@ -31,6 +31,7 @@ import ( | |||||
| 31 | "github.com/bnema/vev/internal/ports" | 31 | "github.com/bnema/vev/internal/ports" | ||
| 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | 32 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 33 | "github.com/bnema/vev/internal/usecase/keys" | 33 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 34 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 34 | "github.com/bnema/vev/pkg/renderer" | 35 | "github.com/bnema/vev/pkg/renderer" | ||
| 35 | ) | 36 | ) | ||
| 36 | 37 | ||||
| @@ -76,10 +77,64 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||||
| 76 | tb.mu.Unlock() | 77 | tb.mu.Unlock() | ||
| 77 | ac.copyMu.Lock() | 78 | ac.copyMu.Lock() | ||
| 78 | ac.copyMode = scopy.NewMode(snap) | 79 | ac.copyMode = scopy.NewMode(snap) | ||
| 80 | + | ac.copyPressRowValid = false | |||
| 81 | + | ac.copyDragging = false | |||
| 82 | + | ac.normalMousePressValid = false | |||
| 79 | ac.copyMu.Unlock() | 83 | ac.copyMu.Unlock() | ||
| 80 | d.paint(sess, ac, true) | 84 | d.paint(sess, ac, true) | ||
| 81 | } | 85 | } | ||
| 82 | 86 | ||||
| 87 | + | func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 88 | + | if ev.Button != mouse.Left { | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | tb := sess.activeTab() | |||
| 92 | + | if tb == nil { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | tb.mu.Lock() | |||
| 97 | + | if ev.Row >= tb.screen.Frame.Height { | |||
| 98 | + | tb.mu.Unlock() | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | ac.copyMu.Lock() | |||
| 102 | + | if ac.copyMode == nil { | |||
| 103 | + | ac.copyMu.Unlock() | |||
| 104 | + | tb.mu.Unlock() | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |||
| 108 | + | absRow := ac.copyMode.ViewportTop + ev.Row | |||
| 109 | + | changed := false | |||
| 110 | + | switch ev.Type { | |||
| 111 | + | case mouse.Press: | |||
| 112 | + | ac.copyMode.SetCursor(snap, absRow) | |||
| 113 | + | ac.copyPressRow = ac.copyMode.Cursor | |||
| 114 | + | ac.copyPressRowValid = true | |||
| 115 | + | ac.copyDragging = false | |||
| 116 | + | changed = true | |||
| 117 | + | case mouse.Motion: | |||
| 118 | + | if !ac.copyPressRowValid { | |||
| 119 | + | break | |||
| 120 | + | } | |||
| 121 | + | if !ac.copyDragging { | |||
| 122 | + | ac.copyMode.StartSelectionAt(snap, ac.copyPressRow) | |||
| 123 | + | ac.copyDragging = true | |||
| 124 | + | } | |||
| 125 | + | ac.copyMode.ExtendTo(snap, absRow) | |||
| 126 | + | changed = true | |||
| 127 | + | case mouse.Release: | |||
| 128 | + | // Button release intentionally has no visual effect. | |||
| 129 | + | } | |||
| 130 | + | ac.copyMu.Unlock() | |||
| 131 | + | tb.mu.Unlock() | |||
| 132 | + | ||||
| 133 | + | if changed { | |||
| 134 | + | d.paint(sess, ac, true) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 83 | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | 138 | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | ||
| 84 | sess := ac.currentSession() | 139 | sess := ac.currentSession() | ||
| 85 | if sess == nil { | 140 | if sess == nil { | ||
| @@ -125,7 +180,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 125 | case 'G': | 180 | case 'G': | ||
| 126 | ac.copyMode.Bottom(snap) | 181 | ac.copyMode.Bottom(snap) | ||
| 127 | changed = true | 182 | changed = true | ||
| 128 | - | case ' ': | 183 | + | case ' ', 'v': |
| 129 | ac.copyMode.ToggleSelection() | 184 | ac.copyMode.ToggleSelection() | ||
| 130 | changed = true | 185 | changed = true | ||
| 131 | case '\r', '\n', 'y': | 186 | case '\r', '\n', 'y': | ||
M internal/usecase/daemon/copymode_test.go +6 -6
| @@ -34,7 +34,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||
| 34 | 34 | ||
| 35 | 35 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 36 | 36 | awaitFrame(t, sends, ports.MsgOutput) | |
| 37 | - | d.handleInput(sess, ac, []byte("CPY\r")) | |
| 37 | + | d.handleInput(sess, ac, []byte("VIS\r")) | |
| 38 | 38 | ||
| 39 | 39 | if ac.copyMode == nil { | |
| 40 | 40 | t.Fatal("scrollback mode not entered") | |
| @@ -47,16 +47,16 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||
| 47 | 47 | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 48 | 48 | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 49 | 49 | require.NoError(t, err) | |
| 50 | - | if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") { | |
| 51 | - | t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got) | |
| 50 | + | if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[COPY]") { | |
| 51 | + | t.Fatalf("visual mode paint = %q, want [VISUAL] without [COPY]", got) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| 54 | 54 | d.handleInput(sess, ac, []byte(" ")) | |
| 55 | 55 | out = awaitFrame(t, sends, ports.MsgOutput) | |
| 56 | 56 | msg, err = ports.UnmarshalOutput(out.Payload) | |
| 57 | 57 | require.NoError(t, err) | |
| 58 | - | if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") { | |
| 59 | - | t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got) | |
| 58 | + | if got := string(msg.Data); !strings.Contains(got, "[SELECT]") || strings.Contains(got, "[SCROLL]") { | |
| 59 | + | t.Fatalf("visual selection paint = %q, want [SELECT] without [SCROLL]", got) | |
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | 62 | ||
| @@ -157,7 +157,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||
| 157 | 157 | require.NotNil(t, ac) | |
| 158 | 158 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 159 | 159 | awaitFrame(t, sends, ports.MsgOutput) | |
| 160 | - | d.handleInput(sess, ac, []byte("CPY\r")) | |
| 160 | + | d.handleInput(sess, ac, []byte("VIS\r")) | |
| 161 | 161 | awaitFrame(t, sends, ports.MsgOutput) | |
| 162 | 162 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | |
| 163 | 163 | ||
| @@ -34,7 +34,7 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||||
| 34 | 34 | ||||
| 35 | d.handleInput(sess, ac, []byte("\x1b ")) | 35 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 36 | awaitFrame(t, sends, ports.MsgOutput) | 36 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 37 | - | d.handleInput(sess, ac, []byte("CPY\r")) | 37 | + | d.handleInput(sess, ac, []byte("VIS\r")) |
| 38 | 38 | ||||
| 39 | if ac.copyMode == nil { | 39 | if ac.copyMode == nil { | ||
| 40 | t.Fatal("scrollback mode not entered") | 40 | t.Fatal("scrollback mode not entered") | ||
| @@ -47,16 +47,16 @@ func TestCopyModePaletteCommandEntersAndDoesNotForward(t *testing.T) { | |||||
| 47 | out := awaitFrame(t, sends, ports.MsgOutput) | 47 | out := awaitFrame(t, sends, ports.MsgOutput) | ||
| 48 | msg, err := ports.UnmarshalOutput(out.Payload) | 48 | msg, err := ports.UnmarshalOutput(out.Payload) | ||
| 49 | require.NoError(t, err) | 49 | require.NoError(t, err) | ||
| 50 | - | if got := string(msg.Data); !strings.Contains(got, "[SCROLL]") || strings.Contains(got, "[COPY]") { | 50 | + | if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[COPY]") { |
| 51 | - | t.Fatalf("scrollback mode paint = %q, want [SCROLL] without [COPY]", got) | 51 | + | t.Fatalf("visual mode paint = %q, want [VISUAL] without [COPY]", got) |
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | d.handleInput(sess, ac, []byte(" ")) | 54 | d.handleInput(sess, ac, []byte(" ")) | ||
| 55 | out = awaitFrame(t, sends, ports.MsgOutput) | 55 | out = awaitFrame(t, sends, ports.MsgOutput) | ||
| 56 | msg, err = ports.UnmarshalOutput(out.Payload) | 56 | msg, err = ports.UnmarshalOutput(out.Payload) | ||
| 57 | require.NoError(t, err) | 57 | require.NoError(t, err) | ||
| 58 | - | if got := string(msg.Data); !strings.Contains(got, "[VISUAL]") || strings.Contains(got, "[SCROLL]") { | 58 | + | if got := string(msg.Data); !strings.Contains(got, "[SELECT]") || strings.Contains(got, "[SCROLL]") { |
| 59 | - | t.Fatalf("visual selection paint = %q, want [VISUAL] without [SCROLL]", got) | 59 | + | t.Fatalf("visual selection paint = %q, want [SELECT] without [SCROLL]", got) |
| 60 | } | 60 | } | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| @@ -157,7 +157,7 @@ func TestScrollbackEvictionFeedsCopyModeYank(t *testing.T) { | |||||
| 157 | require.NotNil(t, ac) | 157 | require.NotNil(t, ac) | ||
| 158 | d.handleInput(sess, ac, []byte("\x1b ")) | 158 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 159 | awaitFrame(t, sends, ports.MsgOutput) | 159 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 160 | - | d.handleInput(sess, ac, []byte("CPY\r")) | 160 | + | d.handleInput(sess, ac, []byte("VIS\r")) |
| 161 | awaitFrame(t, sends, ports.MsgOutput) | 161 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 162 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | 162 | d.handleInput(sess, ac, []byte{'g', ' ', 'G', 'y'}) | ||
| 163 | 163 | ||||
M internal/usecase/daemon/input.go +48 -0
| @@ -26,6 +26,7 @@ | |||
| 26 | 26 | package daemon | |
| 27 | 27 | ||
| 28 | 28 | import ( | |
| 29 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 29 | 30 | "github.com/bnema/vev/internal/usecase/keys" | |
| 30 | 31 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 31 | 32 | ) | |
| @@ -70,6 +71,8 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 70 | 71 | ||
| 71 | 72 | if ac.copyModeActive() { | |
| 72 | 73 | switch ev.Button { | |
| 74 | + | case mouse.Left: | |
| 75 | + | d.copyMouse(sess, ac, ev) | |
| 73 | 76 | case mouse.WheelUp: | |
| 74 | 77 | d.copyWheel(sess, ac, -3) | |
| 75 | 78 | case mouse.WheelDown: | |
| @@ -82,6 +85,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 82 | 85 | childRows := tb.screen.Frame.Height | |
| 83 | 86 | mouseMode, mouseSGR := tb.screen.MouseMode() | |
| 84 | 87 | altScreen := tb.screen.AltScreenActive() | |
| 88 | + | scrollbackRows := 0 | |
| 89 | + | if tb.scrollback != nil { | |
| 90 | + | scrollbackRows = tb.scrollback.Len() | |
| 91 | + | } | |
| 85 | 92 | tb.mu.Unlock() | |
| 86 | 93 | ||
| 87 | 94 | if mouseMode != 0 { | |
| @@ -93,6 +100,47 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 93 | 100 | } | |
| 94 | 101 | ||
| 95 | 102 | switch ev.Button { | |
| 103 | + | case mouse.Left: | |
| 104 | + | if altScreen || ev.Row >= childRows { | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | switch ev.Type { | |
| 108 | + | case mouse.Press: | |
| 109 | + | ac.copyMu.Lock() | |
| 110 | + | ac.normalMousePressRow = ev.Row | |
| 111 | + | ac.normalMousePressTop = scrollbackRows | |
| 112 | + | ac.normalMousePressValid = true | |
| 113 | + | ac.copyMu.Unlock() | |
| 114 | + | case mouse.Motion: | |
| 115 | + | ac.copyMu.Lock() | |
| 116 | + | pressValid := ac.normalMousePressValid | |
| 117 | + | pressRow := ac.normalMousePressRow | |
| 118 | + | pressTop := ac.normalMousePressTop | |
| 119 | + | ac.copyMu.Unlock() | |
| 120 | + | if !pressValid { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | tb.mu.Lock() | |
| 125 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 126 | + | tb.mu.Unlock() | |
| 127 | + | ||
| 128 | + | ac.copyMu.Lock() | |
| 129 | + | mode := scopy.NewMode(snap) | |
| 130 | + | mode.StartSelectionAt(snap, pressTop+pressRow) | |
| 131 | + | mode.ExtendTo(snap, pressTop+ev.Row) | |
| 132 | + | ac.copyMode = mode | |
| 133 | + | ac.copyPressRow = pressTop + pressRow | |
| 134 | + | ac.copyPressRowValid = true | |
| 135 | + | ac.copyDragging = true | |
| 136 | + | ac.normalMousePressValid = false | |
| 137 | + | ac.copyMu.Unlock() | |
| 138 | + | d.paint(sess, ac, true) | |
| 139 | + | case mouse.Release: | |
| 140 | + | ac.copyMu.Lock() | |
| 141 | + | ac.normalMousePressValid = false | |
| 142 | + | ac.copyMu.Unlock() | |
| 143 | + | } | |
| 96 | 144 | case mouse.WheelUp: | |
| 97 | 145 | if altScreen { | |
| 98 | 146 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | |
| @@ -26,6 +26,7 @@ | |||||
| 26 | package daemon | 26 | package daemon | ||
| 27 | 27 | ||||
| 28 | import ( | 28 | import ( | ||
| 29 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 29 | "github.com/bnema/vev/internal/usecase/keys" | 30 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 30 | "github.com/bnema/vev/internal/usecase/mouse" | 31 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 31 | ) | 32 | ) | ||
| @@ -70,6 +71,8 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 70 | 71 | ||||
| 71 | if ac.copyModeActive() { | 72 | if ac.copyModeActive() { | ||
| 72 | switch ev.Button { | 73 | switch ev.Button { | ||
| 74 | + | case mouse.Left: | |||
| 75 | + | d.copyMouse(sess, ac, ev) | |||
| 73 | case mouse.WheelUp: | 76 | case mouse.WheelUp: | ||
| 74 | d.copyWheel(sess, ac, -3) | 77 | d.copyWheel(sess, ac, -3) | ||
| 75 | case mouse.WheelDown: | 78 | case mouse.WheelDown: | ||
| @@ -82,6 +85,10 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 82 | childRows := tb.screen.Frame.Height | 85 | childRows := tb.screen.Frame.Height | ||
| 83 | mouseMode, mouseSGR := tb.screen.MouseMode() | 86 | mouseMode, mouseSGR := tb.screen.MouseMode() | ||
| 84 | altScreen := tb.screen.AltScreenActive() | 87 | altScreen := tb.screen.AltScreenActive() | ||
| 88 | + | scrollbackRows := 0 | |||
| 89 | + | if tb.scrollback != nil { | |||
| 90 | + | scrollbackRows = tb.scrollback.Len() | |||
| 91 | + | } | |||
| 85 | tb.mu.Unlock() | 92 | tb.mu.Unlock() | ||
| 86 | 93 | ||||
| 87 | if mouseMode != 0 { | 94 | if mouseMode != 0 { | ||
| @@ -93,6 +100,47 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 93 | } | 100 | } | ||
| 94 | 101 | ||||
| 95 | switch ev.Button { | 102 | switch ev.Button { | ||
| 103 | + | case mouse.Left: | |||
| 104 | + | if altScreen || ev.Row >= childRows { | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | switch ev.Type { | |||
| 108 | + | case mouse.Press: | |||
| 109 | + | ac.copyMu.Lock() | |||
| 110 | + | ac.normalMousePressRow = ev.Row | |||
| 111 | + | ac.normalMousePressTop = scrollbackRows | |||
| 112 | + | ac.normalMousePressValid = true | |||
| 113 | + | ac.copyMu.Unlock() | |||
| 114 | + | case mouse.Motion: | |||
| 115 | + | ac.copyMu.Lock() | |||
| 116 | + | pressValid := ac.normalMousePressValid | |||
| 117 | + | pressRow := ac.normalMousePressRow | |||
| 118 | + | pressTop := ac.normalMousePressTop | |||
| 119 | + | ac.copyMu.Unlock() | |||
| 120 | + | if !pressValid { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | tb.mu.Lock() | |||
| 125 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |||
| 126 | + | tb.mu.Unlock() | |||
| 127 | + | ||||
| 128 | + | ac.copyMu.Lock() | |||
| 129 | + | mode := scopy.NewMode(snap) | |||
| 130 | + | mode.StartSelectionAt(snap, pressTop+pressRow) | |||
| 131 | + | mode.ExtendTo(snap, pressTop+ev.Row) | |||
| 132 | + | ac.copyMode = mode | |||
| 133 | + | ac.copyPressRow = pressTop + pressRow | |||
| 134 | + | ac.copyPressRowValid = true | |||
| 135 | + | ac.copyDragging = true | |||
| 136 | + | ac.normalMousePressValid = false | |||
| 137 | + | ac.copyMu.Unlock() | |||
| 138 | + | d.paint(sess, ac, true) | |||
| 139 | + | case mouse.Release: | |||
| 140 | + | ac.copyMu.Lock() | |||
| 141 | + | ac.normalMousePressValid = false | |||
| 142 | + | ac.copyMu.Unlock() | |||
| 143 | + | } | |||
| 96 | case mouse.WheelUp: | 144 | case mouse.WheelUp: | ||
| 97 | if altScreen { | 145 | if altScreen { | ||
| 98 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | 146 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | ||
M internal/usecase/daemon/input_test.go +48 -2
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/base64" | |
| 5 | + | "strings" | |
| 4 | 6 | "sync" | |
| 5 | 7 | "testing" | |
| 6 | 8 | "time" | |
| @@ -172,7 +174,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |||
| 172 | 174 | data := mustOutputData(t, sends) | |
| 173 | 175 | require.NotNil(t, ac.copyMode) | |
| 174 | 176 | require.Equal(t, 19, ac.copyMode.Cursor) | |
| 175 | - | require.Contains(t, string(data), "[SCROLL]") | |
| 177 | + | require.Contains(t, string(data), "[VISUAL]") | |
| 176 | 178 | ||
| 177 | 179 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 178 | 180 | mustOutputData(t, sends) | |
| @@ -206,7 +208,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||
| 206 | 208 | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |
| 207 | 209 | writes := make(chan []byte, 4) | |
| 208 | 210 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 209 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 211 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 210 | 212 | ||
| 211 | 213 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 212 | 214 | select { | |
| @@ -214,6 +216,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 214 | 216 | t.Fatalf("press without child mouse mode forwarded: %q", got) | |
| 215 | 217 | default: | |
| 216 | 218 | } | |
| 219 | + | require.Nil(t, ac.copyMode, "press alone must not enter visual mode") | |
| 217 | 220 | ||
| 218 | 221 | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) | |
| 219 | 222 | raw := []byte("\x1b[<0;2;3M") | |
| @@ -234,6 +237,49 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 234 | 237 | t.Fatalf("status-row mouse report forwarded: %q", got) | |
| 235 | 238 | default: | |
| 236 | 239 | } | |
| 240 | + | ||
| 241 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | |
| 242 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 243 | + | require.Nil(t, ac.copyMode, "press alone must still not enter visual mode") | |
| 244 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 245 | + | mustOutputData(t, sends) | |
| 246 | + | require.NotNil(t, ac.copyMode) | |
| 247 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 248 | + | require.True(t, ok) | |
| 249 | + | require.Equal(t, 0, lo) | |
| 250 | + | require.Equal(t, 2, hi) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |
| 254 | + | p, _ := newBlockingPTY(t) | |
| 255 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 256 | + | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |
| 257 | + | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |
| 258 | + | copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie")) | |
| 259 | + | ||
| 260 | + | d.enterCopyMode(sess, ac) | |
| 261 | + | mustOutputData(t, sends) | |
| 262 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 263 | + | mustOutputData(t, sends) | |
| 264 | + | require.NotNil(t, ac.copyMode) | |
| 265 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 266 | + | require.True(t, ok) | |
| 267 | + | require.Equal(t, 0, lo) | |
| 268 | + | require.Equal(t, 1, hi) | |
| 269 | + | ||
| 270 | + | d.handleInput(sess, ac, []byte("y")) | |
| 271 | + | data := "" | |
| 272 | + | require.Eventually(t, func() bool { | |
| 273 | + | data = string(mustOutputData(t, sends)) | |
| 274 | + | return strings.HasPrefix(data, "\x1b]52;c;") | |
| 275 | + | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |
| 276 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | require.Equal(t, "alpha\nbravo", string(decoded)) | |
| 279 | + | require.Nil(t, ac.copyMode) | |
| 280 | + | ||
| 281 | + | exitPaint := string(mustOutputData(t, sends)) | |
| 282 | + | require.NotContains(t, exitPaint, "[SELECT]") | |
| 237 | 283 | } | |
| 238 | 284 | ||
| 239 | 285 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "encoding/base64" | |||
| 5 | + | "strings" | |||
| 4 | "sync" | 6 | "sync" | ||
| 5 | "testing" | 7 | "testing" | ||
| 6 | "time" | 8 | "time" | ||
| @@ -172,7 +174,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |||||
| 172 | data := mustOutputData(t, sends) | 174 | data := mustOutputData(t, sends) | ||
| 173 | require.NotNil(t, ac.copyMode) | 175 | require.NotNil(t, ac.copyMode) | ||
| 174 | require.Equal(t, 19, ac.copyMode.Cursor) | 176 | require.Equal(t, 19, ac.copyMode.Cursor) | ||
| 175 | - | require.Contains(t, string(data), "[SCROLL]") | 177 | + | require.Contains(t, string(data), "[VISUAL]") |
| 176 | 178 | ||||
| 177 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | 179 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | ||
| 178 | mustOutputData(t, sends) | 180 | mustOutputData(t, sends) | ||
| @@ -206,7 +208,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||||
| 206 | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | 208 | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | ||
| 207 | writes := make(chan []byte, 4) | 209 | writes := make(chan []byte, 4) | ||
| 208 | p, _ := newBlockingPTYWithWrites(t, writes) | 210 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 209 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 211 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) |
| 210 | 212 | ||||
| 211 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | 213 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | ||
| 212 | select { | 214 | select { | ||
| @@ -214,6 +216,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 214 | t.Fatalf("press without child mouse mode forwarded: %q", got) | 216 | t.Fatalf("press without child mouse mode forwarded: %q", got) | ||
| 215 | default: | 217 | default: | ||
| 216 | } | 218 | } | ||
| 219 | + | require.Nil(t, ac.copyMode, "press alone must not enter visual mode") | |||
| 217 | 220 | ||||
| 218 | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) | 221 | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) | ||
| 219 | raw := []byte("\x1b[<0;2;3M") | 222 | raw := []byte("\x1b[<0;2;3M") | ||
| @@ -234,6 +237,49 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 234 | t.Fatalf("status-row mouse report forwarded: %q", got) | 237 | t.Fatalf("status-row mouse report forwarded: %q", got) | ||
| 235 | default: | 238 | default: | ||
| 236 | } | 239 | } | ||
| 240 | + | ||||
| 241 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | |||
| 242 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |||
| 243 | + | require.Nil(t, ac.copyMode, "press alone must still not enter visual mode") | |||
| 244 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |||
| 245 | + | mustOutputData(t, sends) | |||
| 246 | + | require.NotNil(t, ac.copyMode) | |||
| 247 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |||
| 248 | + | require.True(t, ok) | |||
| 249 | + | require.Equal(t, 0, lo) | |||
| 250 | + | require.Equal(t, 2, hi) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 254 | + | p, _ := newBlockingPTY(t) | |||
| 255 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 256 | + | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |||
| 257 | + | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |||
| 258 | + | copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie")) | |||
| 259 | + | ||||
| 260 | + | d.enterCopyMode(sess, ac) | |||
| 261 | + | mustOutputData(t, sends) | |||
| 262 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |||
| 263 | + | mustOutputData(t, sends) | |||
| 264 | + | require.NotNil(t, ac.copyMode) | |||
| 265 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |||
| 266 | + | require.True(t, ok) | |||
| 267 | + | require.Equal(t, 0, lo) | |||
| 268 | + | require.Equal(t, 1, hi) | |||
| 269 | + | ||||
| 270 | + | d.handleInput(sess, ac, []byte("y")) | |||
| 271 | + | data := "" | |||
| 272 | + | require.Eventually(t, func() bool { | |||
| 273 | + | data = string(mustOutputData(t, sends)) | |||
| 274 | + | return strings.HasPrefix(data, "\x1b]52;c;") | |||
| 275 | + | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |||
| 276 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |||
| 277 | + | require.NoError(t, err) | |||
| 278 | + | require.Equal(t, "alpha\nbravo", string(decoded)) | |||
| 279 | + | require.Nil(t, ac.copyMode) | |||
| 280 | + | ||||
| 281 | + | exitPaint := string(mustOutputData(t, sends)) | |||
| 282 | + | require.NotContains(t, exitPaint, "[SELECT]") | |||
| 237 | } | 283 | } | ||
| 238 | 284 | ||||
| 239 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | 285 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | ||
M internal/usecase/daemon/palette.go +1 -1
| @@ -125,7 +125,7 @@ func (e paletteExec) Detach() error { | |||
| 125 | 125 | return nil | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | - | func (e paletteExec) EnterCopyMode() error { | |
| 128 | + | func (e paletteExec) EnterVisualMode() error { | |
| 129 | 129 | e.d.enterCopyMode(e.sess, e.ac) | |
| 130 | 130 | return nil | |
| 131 | 131 | } | |
| @@ -125,7 +125,7 @@ func (e paletteExec) Detach() error { | |||||
| 125 | return nil | 125 | return nil | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | func (e paletteExec) EnterCopyMode() error { | 128 | + | func (e paletteExec) EnterVisualMode() error { |
| 129 | e.d.enterCopyMode(e.sess, e.ac) | 129 | e.d.enterCopyMode(e.sess, e.ac) | ||
| 130 | return nil | 130 | return nil | ||
| 131 | } | 131 | } | ||
M internal/usecase/daemon/palette_test.go +1 -1
| @@ -201,7 +201,7 @@ func TestPaletteExecMethods(t *testing.T) { | |||
| 201 | 201 | require.NoError(t, exec.OpenSessionPicker()) | |
| 202 | 202 | require.True(t, ac.pickerActive()) | |
| 203 | 203 | d.closePicker(ac) | |
| 204 | - | require.NoError(t, exec.EnterCopyMode()) | |
| 204 | + | require.NoError(t, exec.EnterVisualMode()) | |
| 205 | 205 | require.True(t, ac.copyModeActive()) | |
| 206 | 206 | ac.copyMu.Lock() | |
| 207 | 207 | ac.copyMode = nil | |
| @@ -201,7 +201,7 @@ func TestPaletteExecMethods(t *testing.T) { | |||||
| 201 | require.NoError(t, exec.OpenSessionPicker()) | 201 | require.NoError(t, exec.OpenSessionPicker()) | ||
| 202 | require.True(t, ac.pickerActive()) | 202 | require.True(t, ac.pickerActive()) | ||
| 203 | d.closePicker(ac) | 203 | d.closePicker(ac) | ||
| 204 | - | require.NoError(t, exec.EnterCopyMode()) | 204 | + | require.NoError(t, exec.EnterVisualMode()) |
| 205 | require.True(t, ac.copyModeActive()) | 205 | require.True(t, ac.copyModeActive()) | ||
| 206 | ac.copyMu.Lock() | 206 | ac.copyMu.Lock() | ||
| 207 | ac.copyMode = nil | 207 | ac.copyMode = nil | ||
M internal/usecase/daemon/render_test.go +45 -0
| @@ -259,6 +259,51 @@ func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) { | |||
| 259 | 259 | ||
| 260 | 260 | // --- resize ordering -------------------------------------------------------- | |
| 261 | 261 | ||
| 262 | + | func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |
| 263 | + | p := portsmocks.NewMockPTY(t) | |
| 264 | + | p.EXPECT().Resize(domain.Size{Cols: 4, Rows: 2}).Return(nil).Once() | |
| 265 | + | p.EXPECT().Resize(domain.Size{Cols: 6, Rows: 4}).Return(nil).Once() | |
| 266 | + | ||
| 267 | + | win := newTab(p, domain.Size{Cols: 4, Rows: 4}) | |
| 268 | + | for y, text := range []string{"0000", "1111", "2222", "3333"} { | |
| 269 | + | copy(win.screen.Frame.Row(y), testRow(text)) | |
| 270 | + | } | |
| 271 | + | win.screen.Row = 3 | |
| 272 | + | ||
| 273 | + | tr := portsmocks.NewMockTransport(t) | |
| 274 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 275 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 276 | + | ||
| 277 | + | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 278 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 279 | + | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | |
| 280 | + | ac.setSession(sess) | |
| 281 | + | ||
| 282 | + | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 3}) | |
| 283 | + | require.Equal(t, "2222", frameRowString(win.screen.Frame, 0)) | |
| 284 | + | require.Equal(t, "3333", frameRowString(win.screen.Frame, 1)) | |
| 285 | + | require.Equal(t, 2, win.scrollback.Len()) | |
| 286 | + | require.Equal(t, "0000", cellsString(win.scrollback.Row(0))) | |
| 287 | + | require.Equal(t, "1111", cellsString(win.scrollback.Row(1))) | |
| 288 | + | ||
| 289 | + | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 5}) | |
| 290 | + | require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0)) | |
| 291 | + | require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1)) | |
| 292 | + | require.Equal(t, 2, win.scrollback.Len()) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func frameRowString(f renderer.Frame, y int) string { | |
| 296 | + | return cellsString(f.Row(y)) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | func cellsString(row []renderer.Cell) string { | |
| 300 | + | runes := make([]rune, len(row)) | |
| 301 | + | for i, c := range row { | |
| 302 | + | runes[i] = c.Rune | |
| 303 | + | } | |
| 304 | + | return string(runes) | |
| 305 | + | } | |
| 306 | + | ||
| 262 | 307 | func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |
| 263 | 308 | newSize := domain.Size{Cols: 100, Rows: 30} | |
| 264 | 309 | ||
| @@ -259,6 +259,51 @@ func TestSchedulerAdaptiveDebounceResetsAfterQuietPeriod(t *testing.T) { | |||||
| 259 | 259 | ||||
| 260 | // --- resize ordering -------------------------------------------------------- | 260 | // --- resize ordering -------------------------------------------------------- | ||
| 261 | 261 | ||||
| 262 | + | func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 263 | + | p := portsmocks.NewMockPTY(t) | |||
| 264 | + | p.EXPECT().Resize(domain.Size{Cols: 4, Rows: 2}).Return(nil).Once() | |||
| 265 | + | p.EXPECT().Resize(domain.Size{Cols: 6, Rows: 4}).Return(nil).Once() | |||
| 266 | + | ||||
| 267 | + | win := newTab(p, domain.Size{Cols: 4, Rows: 4}) | |||
| 268 | + | for y, text := range []string{"0000", "1111", "2222", "3333"} { | |||
| 269 | + | copy(win.screen.Frame.Row(y), testRow(text)) | |||
| 270 | + | } | |||
| 271 | + | win.screen.Row = 3 | |||
| 272 | + | ||||
| 273 | + | tr := portsmocks.NewMockTransport(t) | |||
| 274 | + | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 275 | + | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |||
| 276 | + | ||||
| 277 | + | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |||
| 278 | + | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |||
| 279 | + | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | |||
| 280 | + | ac.setSession(sess) | |||
| 281 | + | ||||
| 282 | + | d.resize(sess, ac, domain.Size{Cols: 4, Rows: 3}) | |||
| 283 | + | require.Equal(t, "2222", frameRowString(win.screen.Frame, 0)) | |||
| 284 | + | require.Equal(t, "3333", frameRowString(win.screen.Frame, 1)) | |||
| 285 | + | require.Equal(t, 2, win.scrollback.Len()) | |||
| 286 | + | require.Equal(t, "0000", cellsString(win.scrollback.Row(0))) | |||
| 287 | + | require.Equal(t, "1111", cellsString(win.scrollback.Row(1))) | |||
| 288 | + | ||||
| 289 | + | d.resize(sess, ac, domain.Size{Cols: 6, Rows: 5}) | |||
| 290 | + | require.Equal(t, "2222 ", frameRowString(win.screen.Frame, 0)) | |||
| 291 | + | require.Equal(t, "3333 ", frameRowString(win.screen.Frame, 1)) | |||
| 292 | + | require.Equal(t, 2, win.scrollback.Len()) | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func frameRowString(f renderer.Frame, y int) string { | |||
| 296 | + | return cellsString(f.Row(y)) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | func cellsString(row []renderer.Cell) string { | |||
| 300 | + | runes := make([]rune, len(row)) | |||
| 301 | + | for i, c := range row { | |||
| 302 | + | runes[i] = c.Rune | |||
| 303 | + | } | |||
| 304 | + | return string(runes) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 262 | func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | 307 | func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | ||
| 263 | newSize := domain.Size{Cols: 100, Rows: 30} | 308 | newSize := domain.Size{Cols: 100, Rows: 30} | ||
| 264 | 309 | ||||
M pkg/vt/screen.go +59 -10
| @@ -70,17 +70,39 @@ func (s *Screen) Resize(width, height int) { | |||
| 70 | 70 | if width == s.Frame.Width && height == s.Frame.Height { | |
| 71 | 71 | return | |
| 72 | 72 | } | |
| 73 | - | s.Frame = renderer.NewFrame(width, height) | |
| 74 | - | s.Row, s.Col = 0, 0 | |
| 75 | - | s.Style = renderer.DefaultStyle() | |
| 73 | + | evict := func(row []renderer.Cell) { | |
| 74 | + | if s.OnLineEvicted != nil { | |
| 75 | + | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if s.alternate != nil { | |
| 80 | + | var shift int | |
| 81 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | |
| 82 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 83 | + | s.Col = clamp(s.Col, 0, width-1) | |
| 84 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 85 | + | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | |
| 86 | + | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | |
| 87 | + | s.alternate.col = clamp(s.alternate.col, 0, width-1) | |
| 88 | + | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |
| 89 | + | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |
| 90 | + | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |
| 91 | + | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |
| 92 | + | s.alternate.scrollTop = 0 | |
| 93 | + | s.alternate.scrollBottom = max(height-1, 0) | |
| 94 | + | } | |
| 95 | + | } else { | |
| 96 | + | var shift int | |
| 97 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | |
| 98 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |
| 99 | + | s.Col = clamp(s.Col, 0, width-1) | |
| 100 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // A resize can split an in-flight escape sequence from the terminal state it | |
| 104 | + | // was meant to mutate; keep the durable child state but discard partial bytes. | |
| 76 | 105 | s.escapeBuf = s.escapeBuf[:0] | |
| 77 | - | s.savedCursor = cursorState{} | |
| 78 | - | s.alternate = nil | |
| 79 | - | s.cursorVisible = true | |
| 80 | - | s.cursorStyle = 0 | |
| 81 | - | s.cursorStyleSet = false | |
| 82 | - | s.mouseMode = 0 | |
| 83 | - | s.mouseSGR = false | |
| 84 | 106 | s.resetScrollRegion() | |
| 85 | 107 | s.fullRedraw() | |
| 86 | 108 | } | |
| @@ -894,6 +916,33 @@ func (s *Screen) exitAlternateScreen() { | |||
| 894 | 916 | s.fullRedraw() | |
| 895 | 917 | } | |
| 896 | 918 | ||
| 919 | + | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |
| 920 | + | next := renderer.NewFrame(newW, newH) | |
| 921 | + | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |
| 922 | + | if evict != nil { | |
| 923 | + | for y := 0; y < shift; y++ { | |
| 924 | + | evict(old.Row(y)) | |
| 925 | + | } | |
| 926 | + | } | |
| 927 | + | for dy := 0; dy < newH; dy++ { | |
| 928 | + | sy := dy + shift | |
| 929 | + | if sy >= old.Height { | |
| 930 | + | break | |
| 931 | + | } | |
| 932 | + | copy(next.Row(dy), old.Row(sy)) | |
| 933 | + | } | |
| 934 | + | return next, shift | |
| 935 | + | } | |
| 936 | + | ||
| 937 | + | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |
| 938 | + | if !cur.saved { | |
| 939 | + | return cur | |
| 940 | + | } | |
| 941 | + | cur.row = clamp(cur.row-shift, 0, height-1) | |
| 942 | + | cur.col = clamp(cur.col, 0, width-1) | |
| 943 | + | return cur | |
| 944 | + | } | |
| 945 | + | ||
| 897 | 946 | // cloneFrame produces an independent copy in canonical layout: it copies the | |
| 898 | 947 | // source's logical rows (via Row) into a fresh frame whose offsets are already | |
| 899 | 948 | // canonical, so a rotated source is normalized in the clone. | |
| @@ -70,17 +70,39 @@ func (s *Screen) Resize(width, height int) { | |||||
| 70 | if width == s.Frame.Width && height == s.Frame.Height { | 70 | if width == s.Frame.Width && height == s.Frame.Height { | ||
| 71 | return | 71 | return | ||
| 72 | } | 72 | } | ||
| 73 | - | s.Frame = renderer.NewFrame(width, height) | 73 | + | evict := func(row []renderer.Cell) { |
| 74 | - | s.Row, s.Col = 0, 0 | 74 | + | if s.OnLineEvicted != nil { |
| 75 | - | s.Style = renderer.DefaultStyle() | 75 | + | s.OnLineEvicted(append([]renderer.Cell(nil), row...)) |
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | if s.alternate != nil { | |||
| 80 | + | var shift int | |||
| 81 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil) | |||
| 82 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |||
| 83 | + | s.Col = clamp(s.Col, 0, width-1) | |||
| 84 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |||
| 85 | + | s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict) | |||
| 86 | + | s.alternate.row = clamp(s.alternate.row-shift, 0, height-1) | |||
| 87 | + | s.alternate.col = clamp(s.alternate.col, 0, width-1) | |||
| 88 | + | s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height) | |||
| 89 | + | s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1) | |||
| 90 | + | s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1) | |||
| 91 | + | if s.alternate.scrollTop >= s.alternate.scrollBottom { | |||
| 92 | + | s.alternate.scrollTop = 0 | |||
| 93 | + | s.alternate.scrollBottom = max(height-1, 0) | |||
| 94 | + | } | |||
| 95 | + | } else { | |||
| 96 | + | var shift int | |||
| 97 | + | s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict) | |||
| 98 | + | s.Row = clamp(s.Row-shift, 0, height-1) | |||
| 99 | + | s.Col = clamp(s.Col, 0, width-1) | |||
| 100 | + | s.savedCursor = resizedCursor(s.savedCursor, shift, width, height) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // A resize can split an in-flight escape sequence from the terminal state it | |||
| 104 | + | // was meant to mutate; keep the durable child state but discard partial bytes. | |||
| 76 | s.escapeBuf = s.escapeBuf[:0] | 105 | s.escapeBuf = s.escapeBuf[:0] | ||
| 77 | - | s.savedCursor = cursorState{} | |||
| 78 | - | s.alternate = nil | |||
| 79 | - | s.cursorVisible = true | |||
| 80 | - | s.cursorStyle = 0 | |||
| 81 | - | s.cursorStyleSet = false | |||
| 82 | - | s.mouseMode = 0 | |||
| 83 | - | s.mouseSGR = false | |||
| 84 | s.resetScrollRegion() | 106 | s.resetScrollRegion() | ||
| 85 | s.fullRedraw() | 107 | s.fullRedraw() | ||
| 86 | } | 108 | } | ||
| @@ -894,6 +916,33 @@ func (s *Screen) exitAlternateScreen() { | |||||
| 894 | s.fullRedraw() | 916 | s.fullRedraw() | ||
| 895 | } | 917 | } | ||
| 896 | 918 | ||||
| 919 | + | func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) { | |||
| 920 | + | next := renderer.NewFrame(newW, newH) | |||
| 921 | + | shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0)) | |||
| 922 | + | if evict != nil { | |||
| 923 | + | for y := 0; y < shift; y++ { | |||
| 924 | + | evict(old.Row(y)) | |||
| 925 | + | } | |||
| 926 | + | } | |||
| 927 | + | for dy := 0; dy < newH; dy++ { | |||
| 928 | + | sy := dy + shift | |||
| 929 | + | if sy >= old.Height { | |||
| 930 | + | break | |||
| 931 | + | } | |||
| 932 | + | copy(next.Row(dy), old.Row(sy)) | |||
| 933 | + | } | |||
| 934 | + | return next, shift | |||
| 935 | + | } | |||
| 936 | + | ||||
| 937 | + | func resizedCursor(cur cursorState, shift, width, height int) cursorState { | |||
| 938 | + | if !cur.saved { | |||
| 939 | + | return cur | |||
| 940 | + | } | |||
| 941 | + | cur.row = clamp(cur.row-shift, 0, height-1) | |||
| 942 | + | cur.col = clamp(cur.col, 0, width-1) | |||
| 943 | + | return cur | |||
| 944 | + | } | |||
| 945 | + | ||||
| 897 | // cloneFrame produces an independent copy in canonical layout: it copies the | 946 | // cloneFrame produces an independent copy in canonical layout: it copies the | ||
| 898 | // source's logical rows (via Row) into a fresh frame whose offsets are already | 947 | // source's logical rows (via Row) into a fresh frame whose offsets are already | ||
| 899 | // canonical, so a rotated source is normalized in the clone. | 948 | // canonical, so a rotated source is normalized in the clone. | ||
M pkg/vt/screen_test.go +185 -23
| @@ -1082,48 +1082,210 @@ func TestDamageCoalescingBehavior(t *testing.T) { | |||
| 1082 | 1082 | // --------------------------------------------------------------------------- | |
| 1083 | 1083 | ||
| 1084 | 1084 | func TestResize(t *testing.T) { | |
| 1085 | + | setRow := func(s *Screen, y int, text string) { | |
| 1086 | + | for x, r := range []rune(text) { | |
| 1087 | + | if x >= s.Frame.Width { | |
| 1088 | + | break | |
| 1089 | + | } | |
| 1090 | + | s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 1091 | + | } | |
| 1092 | + | } | |
| 1093 | + | rowString := func(s *Screen, y int) string { | |
| 1094 | + | runes := make([]rune, s.Frame.Width) | |
| 1095 | + | for x := range s.Frame.Width { | |
| 1096 | + | runes[x] = s.Frame.At(x, y).Rune | |
| 1097 | + | } | |
| 1098 | + | return string(runes) | |
| 1099 | + | } | |
| 1100 | + | ||
| 1085 | 1101 | tests := []struct { | |
| 1086 | 1102 | name string | |
| 1087 | 1103 | run func(t *testing.T) | |
| 1088 | 1104 | }{ | |
| 1089 | 1105 | { | |
| 1090 | - | name: "resizing larger clears the frame and forces FullRedraw", | |
| 1106 | + | name: "same size preserves content and skips FullRedraw", | |
| 1091 | 1107 | run: func(t *testing.T) { | |
| 1092 | 1108 | s := NewScreen(5, 3) | |
| 1093 | - | s.Write([]byte("Hello")) | |
| 1094 | - | s.Resize(10, 5) | |
| 1109 | + | s.ClearDamage() | |
| 1110 | + | setRow(s, 0, "Hello") | |
| 1111 | + | ||
| 1112 | + | s.Resize(5, 3) | |
| 1095 | 1113 | ||
| 1096 | - | if s.Frame.Width != 10 || s.Frame.Height != 5 { | |
| 1097 | - | t.Errorf("frame size = %dx%d, want 10x5", s.Frame.Width, s.Frame.Height) | |
| 1114 | + | if got := rowString(s, 0); got != "Hello" { | |
| 1115 | + | t.Fatalf("row 0 = %q, want Hello", got) | |
| 1098 | 1116 | } | |
| 1099 | - | // Should be blank (new frame). | |
| 1100 | - | for x := range 10 { | |
| 1101 | - | assertCell(t, s, x, 0, ' ') | |
| 1117 | + | if d := s.Damage(); len(d) != 0 { | |
| 1118 | + | t.Fatalf("same-size resize damage = %v, want none", damageKinds(d)) | |
| 1102 | 1119 | } | |
| 1103 | - | // Cursor reset. | |
| 1104 | - | if s.Row != 0 || s.Col != 0 { | |
| 1105 | - | t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col) | |
| 1120 | + | }, | |
| 1121 | + | }, | |
| 1122 | + | { | |
| 1123 | + | name: "grow preserves visible content and cursor", | |
| 1124 | + | run: func(t *testing.T) { | |
| 1125 | + | s := NewScreen(5, 2) | |
| 1126 | + | setRow(s, 0, "Hello") | |
| 1127 | + | setRow(s, 1, "World") | |
| 1128 | + | s.Row, s.Col = 1, 4 | |
| 1129 | + | ||
| 1130 | + | s.Resize(8, 4) | |
| 1131 | + | ||
| 1132 | + | if got := rowString(s, 0); got != "Hello " { | |
| 1133 | + | t.Fatalf("row 0 = %q", got) | |
| 1106 | 1134 | } | |
| 1107 | - | // Should have FullRedraw. | |
| 1108 | - | d := s.Damage() | |
| 1109 | - | if !hasDamageKind(d, renderer.DamageFullRedraw) { | |
| 1110 | - | t.Errorf("expected FullRedraw after resize, got %v", damageKinds(d)) | |
| 1135 | + | if got := rowString(s, 1); got != "World " { | |
| 1136 | + | t.Fatalf("row 1 = %q", got) | |
| 1137 | + | } | |
| 1138 | + | if s.Row != 1 || s.Col != 4 { | |
| 1139 | + | t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col) | |
| 1140 | + | } | |
| 1141 | + | if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) { | |
| 1142 | + | t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage())) | |
| 1111 | 1143 | } | |
| 1112 | 1144 | }, | |
| 1113 | 1145 | }, | |
| 1114 | 1146 | { | |
| 1115 | - | name: "resizing to the same size preserves content and skips FullRedraw", | |
| 1147 | + | name: "height shrink evicts top lines and follows cursor", | |
| 1148 | + | run: func(t *testing.T) { | |
| 1149 | + | s := NewScreen(4, 5) | |
| 1150 | + | for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} { | |
| 1151 | + | setRow(s, y, text) | |
| 1152 | + | } | |
| 1153 | + | s.Row, s.Col = 4, 2 | |
| 1154 | + | var evicted []string | |
| 1155 | + | s.OnLineEvicted = func(row []renderer.Cell) { | |
| 1156 | + | runes := make([]rune, len(row)) | |
| 1157 | + | for i, c := range row { | |
| 1158 | + | runes[i] = c.Rune | |
| 1159 | + | } | |
| 1160 | + | evicted = append(evicted, string(runes)) | |
| 1161 | + | } | |
| 1162 | + | ||
| 1163 | + | s.Resize(4, 3) | |
| 1164 | + | ||
| 1165 | + | if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") { | |
| 1166 | + | t.Fatalf("evicted = %v, want %v", got, want) | |
| 1167 | + | } | |
| 1168 | + | if got := rowString(s, 0); got != "2222" { | |
| 1169 | + | t.Fatalf("row 0 = %q", got) | |
| 1170 | + | } | |
| 1171 | + | if s.Row != 2 || s.Col != 2 { | |
| 1172 | + | t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col) | |
| 1173 | + | } | |
| 1174 | + | if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) { | |
| 1175 | + | t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage())) | |
| 1176 | + | } | |
| 1177 | + | }, | |
| 1178 | + | }, | |
| 1179 | + | { | |
| 1180 | + | name: "width truncates and pads without reflow", | |
| 1181 | + | run: func(t *testing.T) { | |
| 1182 | + | s := NewScreen(6, 2) | |
| 1183 | + | setRow(s, 0, "abcdef") | |
| 1184 | + | setRow(s, 1, "ghijkl") | |
| 1185 | + | ||
| 1186 | + | s.Resize(3, 2) | |
| 1187 | + | if got := rowString(s, 0); got != "abc" { | |
| 1188 | + | t.Fatalf("truncated row = %q", got) | |
| 1189 | + | } | |
| 1190 | + | s.Resize(5, 2) | |
| 1191 | + | if got := rowString(s, 1); got != "ghi " { | |
| 1192 | + | t.Fatalf("padded row = %q", got) | |
| 1193 | + | } | |
| 1194 | + | }, | |
| 1195 | + | }, | |
| 1196 | + | { | |
| 1197 | + | name: "cursor and saved cursor clamp", | |
| 1198 | + | run: func(t *testing.T) { | |
| 1199 | + | s := NewScreen(6, 4) | |
| 1200 | + | s.Row, s.Col = 3, 5 | |
| 1201 | + | s.saveCursor() | |
| 1202 | + | ||
| 1203 | + | s.Resize(3, 2) | |
| 1204 | + | ||
| 1205 | + | if s.Row != 1 || s.Col != 2 { | |
| 1206 | + | t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |
| 1207 | + | } | |
| 1208 | + | s.Row, s.Col = 0, 0 | |
| 1209 | + | s.restoreCursor() | |
| 1210 | + | if s.Row != 1 || s.Col != 2 { | |
| 1211 | + | t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |
| 1212 | + | } | |
| 1213 | + | }, | |
| 1214 | + | }, | |
| 1215 | + | { | |
| 1216 | + | name: "alternate resize keeps saved normal content", | |
| 1116 | 1217 | run: func(t *testing.T) { | |
| 1117 | 1218 | s := NewScreen(5, 3) | |
| 1118 | - | s.Write([]byte("Hello")) | |
| 1219 | + | setRow(s, 0, "shell") | |
| 1220 | + | s.Row = 0 | |
| 1221 | + | s.Write([]byte("\x1b[?1049h")) | |
| 1222 | + | setRow(s, 0, "alt!!") | |
| 1119 | 1223 | ||
| 1120 | - | s.Resize(5, 3) // same size | |
| 1224 | + | s.Resize(7, 4) | |
| 1225 | + | s.Write([]byte("\x1b[?1049l")) | |
| 1121 | 1226 | ||
| 1122 | - | assertCell(t, s, 0, 0, 'H') // content preserved | |
| 1123 | - | // No FullRedraw. | |
| 1124 | - | d := s.Damage() | |
| 1125 | - | if len(d) != 0 && d[0].Kind == renderer.DamageFullRedraw { | |
| 1126 | - | t.Error("same-size resize should not produce FullRedraw") | |
| 1227 | + | if got := rowString(s, 0); got != "shell " { | |
| 1228 | + | t.Fatalf("normal row after alt resize = %q", got) | |
| 1229 | + | } | |
| 1230 | + | }, | |
| 1231 | + | }, | |
| 1232 | + | { | |
| 1233 | + | name: "alternate resize shifts active and saved cursors", | |
| 1234 | + | run: func(t *testing.T) { | |
| 1235 | + | s := NewScreen(5, 5) | |
| 1236 | + | s.Row, s.Col = 3, 4 | |
| 1237 | + | s.saveCursor() | |
| 1238 | + | s.scrollTop, s.scrollBottom = 1, 4 | |
| 1239 | + | s.Row = 4 | |
| 1240 | + | s.Write([]byte("\x1b[?1049h")) | |
| 1241 | + | s.Row, s.Col = 3, 4 | |
| 1242 | + | s.saveCursor() | |
| 1243 | + | s.Row = 4 | |
| 1244 | + | ||
| 1245 | + | // The expected rows account for the resize shift that keeps the | |
| 1246 | + | // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor, | |
| 1247 | + | // not just width/height clamping. | |
| 1248 | + | s.Resize(3, 3) | |
| 1249 | + | ||
| 1250 | + | if s.Row != 2 || s.Col != 2 { | |
| 1251 | + | t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col) | |
| 1252 | + | } | |
| 1253 | + | s.Row, s.Col = 0, 0 | |
| 1254 | + | s.restoreCursor() | |
| 1255 | + | if s.Row != 1 || s.Col != 2 { | |
| 1256 | + | t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |
| 1257 | + | } | |
| 1258 | + | ||
| 1259 | + | s.Write([]byte("\x1b[?1049l")) | |
| 1260 | + | if s.scrollTop != 0 || s.scrollBottom != 2 { | |
| 1261 | + | t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom) | |
| 1262 | + | } | |
| 1263 | + | s.Row, s.Col = 0, 0 | |
| 1264 | + | s.restoreCursor() | |
| 1265 | + | if s.Row != 1 || s.Col != 2 { | |
| 1266 | + | t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |
| 1267 | + | } | |
| 1268 | + | }, | |
| 1269 | + | }, | |
| 1270 | + | { | |
| 1271 | + | name: "style mouse and cursor modes survive", | |
| 1272 | + | run: func(t *testing.T) { | |
| 1273 | + | s := NewScreen(5, 2) | |
| 1274 | + | s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?25l\x1b[3 q")) | |
| 1275 | + | ||
| 1276 | + | s.Resize(6, 3) | |
| 1277 | + | ||
| 1278 | + | if !s.Style.Bold { | |
| 1279 | + | t.Fatal("bold style was reset") | |
| 1280 | + | } | |
| 1281 | + | if mode, sgr := s.MouseMode(); mode != 1000 || !sgr { | |
| 1282 | + | t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr) | |
| 1283 | + | } | |
| 1284 | + | if s.CursorVisible() { | |
| 1285 | + | t.Fatal("cursor visibility was reset") | |
| 1286 | + | } | |
| 1287 | + | if style, ok := s.CursorStyle(); style != 3 || !ok { | |
| 1288 | + | t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok) | |
| 1127 | 1289 | } | |
| 1128 | 1290 | }, | |
| 1129 | 1291 | }, | |
| @@ -1082,48 +1082,210 @@ func TestDamageCoalescingBehavior(t *testing.T) { | |||||
| 1082 | // --------------------------------------------------------------------------- | 1082 | // --------------------------------------------------------------------------- | ||
| 1083 | 1083 | ||||
| 1084 | func TestResize(t *testing.T) { | 1084 | func TestResize(t *testing.T) { | ||
| 1085 | + | setRow := func(s *Screen, y int, text string) { | |||
| 1086 | + | for x, r := range []rune(text) { | |||
| 1087 | + | if x >= s.Frame.Width { | |||
| 1088 | + | break | |||
| 1089 | + | } | |||
| 1090 | + | s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 1091 | + | } | |||
| 1092 | + | } | |||
| 1093 | + | rowString := func(s *Screen, y int) string { | |||
| 1094 | + | runes := make([]rune, s.Frame.Width) | |||
| 1095 | + | for x := range s.Frame.Width { | |||
| 1096 | + | runes[x] = s.Frame.At(x, y).Rune | |||
| 1097 | + | } | |||
| 1098 | + | return string(runes) | |||
| 1099 | + | } | |||
| 1100 | + | ||||
| 1085 | tests := []struct { | 1101 | tests := []struct { | ||
| 1086 | name string | 1102 | name string | ||
| 1087 | run func(t *testing.T) | 1103 | run func(t *testing.T) | ||
| 1088 | }{ | 1104 | }{ | ||
| 1089 | { | 1105 | { | ||
| 1090 | - | name: "resizing larger clears the frame and forces FullRedraw", | 1106 | + | name: "same size preserves content and skips FullRedraw", |
| 1091 | run: func(t *testing.T) { | 1107 | run: func(t *testing.T) { | ||
| 1092 | s := NewScreen(5, 3) | 1108 | s := NewScreen(5, 3) | ||
| 1093 | - | s.Write([]byte("Hello")) | 1109 | + | s.ClearDamage() |
| 1094 | - | s.Resize(10, 5) | 1110 | + | setRow(s, 0, "Hello") |
| 1111 | + | ||||
| 1112 | + | s.Resize(5, 3) | |||
| 1095 | 1113 | ||||
| 1096 | - | if s.Frame.Width != 10 || s.Frame.Height != 5 { | 1114 | + | if got := rowString(s, 0); got != "Hello" { |
| 1097 | - | t.Errorf("frame size = %dx%d, want 10x5", s.Frame.Width, s.Frame.Height) | 1115 | + | t.Fatalf("row 0 = %q, want Hello", got) |
| 1098 | } | 1116 | } | ||
| 1099 | - | // Should be blank (new frame). | 1117 | + | if d := s.Damage(); len(d) != 0 { |
| 1100 | - | for x := range 10 { | 1118 | + | t.Fatalf("same-size resize damage = %v, want none", damageKinds(d)) |
| 1101 | - | assertCell(t, s, x, 0, ' ') | |||
| 1102 | } | 1119 | } | ||
| 1103 | - | // Cursor reset. | 1120 | + | }, |
| 1104 | - | if s.Row != 0 || s.Col != 0 { | 1121 | + | }, |
| 1105 | - | t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col) | 1122 | + | { |
| 1123 | + | name: "grow preserves visible content and cursor", | |||
| 1124 | + | run: func(t *testing.T) { | |||
| 1125 | + | s := NewScreen(5, 2) | |||
| 1126 | + | setRow(s, 0, "Hello") | |||
| 1127 | + | setRow(s, 1, "World") | |||
| 1128 | + | s.Row, s.Col = 1, 4 | |||
| 1129 | + | ||||
| 1130 | + | s.Resize(8, 4) | |||
| 1131 | + | ||||
| 1132 | + | if got := rowString(s, 0); got != "Hello " { | |||
| 1133 | + | t.Fatalf("row 0 = %q", got) | |||
| 1106 | } | 1134 | } | ||
| 1107 | - | // Should have FullRedraw. | 1135 | + | if got := rowString(s, 1); got != "World " { |
| 1108 | - | d := s.Damage() | 1136 | + | t.Fatalf("row 1 = %q", got) |
| 1109 | - | if !hasDamageKind(d, renderer.DamageFullRedraw) { | 1137 | + | } |
| 1110 | - | t.Errorf("expected FullRedraw after resize, got %v", damageKinds(d)) | 1138 | + | if s.Row != 1 || s.Col != 4 { |
| 1139 | + | t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col) | |||
| 1140 | + | } | |||
| 1141 | + | if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) { | |||
| 1142 | + | t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage())) | |||
| 1111 | } | 1143 | } | ||
| 1112 | }, | 1144 | }, | ||
| 1113 | }, | 1145 | }, | ||
| 1114 | { | 1146 | { | ||
| 1115 | - | name: "resizing to the same size preserves content and skips FullRedraw", | 1147 | + | name: "height shrink evicts top lines and follows cursor", |
| 1148 | + | run: func(t *testing.T) { | |||
| 1149 | + | s := NewScreen(4, 5) | |||
| 1150 | + | for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} { | |||
| 1151 | + | setRow(s, y, text) | |||
| 1152 | + | } | |||
| 1153 | + | s.Row, s.Col = 4, 2 | |||
| 1154 | + | var evicted []string | |||
| 1155 | + | s.OnLineEvicted = func(row []renderer.Cell) { | |||
| 1156 | + | runes := make([]rune, len(row)) | |||
| 1157 | + | for i, c := range row { | |||
| 1158 | + | runes[i] = c.Rune | |||
| 1159 | + | } | |||
| 1160 | + | evicted = append(evicted, string(runes)) | |||
| 1161 | + | } | |||
| 1162 | + | ||||
| 1163 | + | s.Resize(4, 3) | |||
| 1164 | + | ||||
| 1165 | + | if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") { | |||
| 1166 | + | t.Fatalf("evicted = %v, want %v", got, want) | |||
| 1167 | + | } | |||
| 1168 | + | if got := rowString(s, 0); got != "2222" { | |||
| 1169 | + | t.Fatalf("row 0 = %q", got) | |||
| 1170 | + | } | |||
| 1171 | + | if s.Row != 2 || s.Col != 2 { | |||
| 1172 | + | t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col) | |||
| 1173 | + | } | |||
| 1174 | + | if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) { | |||
| 1175 | + | t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage())) | |||
| 1176 | + | } | |||
| 1177 | + | }, | |||
| 1178 | + | }, | |||
| 1179 | + | { | |||
| 1180 | + | name: "width truncates and pads without reflow", | |||
| 1181 | + | run: func(t *testing.T) { | |||
| 1182 | + | s := NewScreen(6, 2) | |||
| 1183 | + | setRow(s, 0, "abcdef") | |||
| 1184 | + | setRow(s, 1, "ghijkl") | |||
| 1185 | + | ||||
| 1186 | + | s.Resize(3, 2) | |||
| 1187 | + | if got := rowString(s, 0); got != "abc" { | |||
| 1188 | + | t.Fatalf("truncated row = %q", got) | |||
| 1189 | + | } | |||
| 1190 | + | s.Resize(5, 2) | |||
| 1191 | + | if got := rowString(s, 1); got != "ghi " { | |||
| 1192 | + | t.Fatalf("padded row = %q", got) | |||
| 1193 | + | } | |||
| 1194 | + | }, | |||
| 1195 | + | }, | |||
| 1196 | + | { | |||
| 1197 | + | name: "cursor and saved cursor clamp", | |||
| 1198 | + | run: func(t *testing.T) { | |||
| 1199 | + | s := NewScreen(6, 4) | |||
| 1200 | + | s.Row, s.Col = 3, 5 | |||
| 1201 | + | s.saveCursor() | |||
| 1202 | + | ||||
| 1203 | + | s.Resize(3, 2) | |||
| 1204 | + | ||||
| 1205 | + | if s.Row != 1 || s.Col != 2 { | |||
| 1206 | + | t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 1207 | + | } | |||
| 1208 | + | s.Row, s.Col = 0, 0 | |||
| 1209 | + | s.restoreCursor() | |||
| 1210 | + | if s.Row != 1 || s.Col != 2 { | |||
| 1211 | + | t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 1212 | + | } | |||
| 1213 | + | }, | |||
| 1214 | + | }, | |||
| 1215 | + | { | |||
| 1216 | + | name: "alternate resize keeps saved normal content", | |||
| 1116 | run: func(t *testing.T) { | 1217 | run: func(t *testing.T) { | ||
| 1117 | s := NewScreen(5, 3) | 1218 | s := NewScreen(5, 3) | ||
| 1118 | - | s.Write([]byte("Hello")) | 1219 | + | setRow(s, 0, "shell") |
| 1220 | + | s.Row = 0 | |||
| 1221 | + | s.Write([]byte("\x1b[?1049h")) | |||
| 1222 | + | setRow(s, 0, "alt!!") | |||
| 1119 | 1223 | ||||
| 1120 | - | s.Resize(5, 3) // same size | 1224 | + | s.Resize(7, 4) |
| 1225 | + | s.Write([]byte("\x1b[?1049l")) | |||
| 1121 | 1226 | ||||
| 1122 | - | assertCell(t, s, 0, 0, 'H') // content preserved | 1227 | + | if got := rowString(s, 0); got != "shell " { |
| 1123 | - | // No FullRedraw. | 1228 | + | t.Fatalf("normal row after alt resize = %q", got) |
| 1124 | - | d := s.Damage() | 1229 | + | } |
| 1125 | - | if len(d) != 0 && d[0].Kind == renderer.DamageFullRedraw { | 1230 | + | }, |
| 1126 | - | t.Error("same-size resize should not produce FullRedraw") | 1231 | + | }, |
| 1232 | + | { | |||
| 1233 | + | name: "alternate resize shifts active and saved cursors", | |||
| 1234 | + | run: func(t *testing.T) { | |||
| 1235 | + | s := NewScreen(5, 5) | |||
| 1236 | + | s.Row, s.Col = 3, 4 | |||
| 1237 | + | s.saveCursor() | |||
| 1238 | + | s.scrollTop, s.scrollBottom = 1, 4 | |||
| 1239 | + | s.Row = 4 | |||
| 1240 | + | s.Write([]byte("\x1b[?1049h")) | |||
| 1241 | + | s.Row, s.Col = 3, 4 | |||
| 1242 | + | s.saveCursor() | |||
| 1243 | + | s.Row = 4 | |||
| 1244 | + | ||||
| 1245 | + | // The expected rows account for the resize shift that keeps the | |||
| 1246 | + | // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor, | |||
| 1247 | + | // not just width/height clamping. | |||
| 1248 | + | s.Resize(3, 3) | |||
| 1249 | + | ||||
| 1250 | + | if s.Row != 2 || s.Col != 2 { | |||
| 1251 | + | t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col) | |||
| 1252 | + | } | |||
| 1253 | + | s.Row, s.Col = 0, 0 | |||
| 1254 | + | s.restoreCursor() | |||
| 1255 | + | if s.Row != 1 || s.Col != 2 { | |||
| 1256 | + | t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 1257 | + | } | |||
| 1258 | + | ||||
| 1259 | + | s.Write([]byte("\x1b[?1049l")) | |||
| 1260 | + | if s.scrollTop != 0 || s.scrollBottom != 2 { | |||
| 1261 | + | t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom) | |||
| 1262 | + | } | |||
| 1263 | + | s.Row, s.Col = 0, 0 | |||
| 1264 | + | s.restoreCursor() | |||
| 1265 | + | if s.Row != 1 || s.Col != 2 { | |||
| 1266 | + | t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col) | |||
| 1267 | + | } | |||
| 1268 | + | }, | |||
| 1269 | + | }, | |||
| 1270 | + | { | |||
| 1271 | + | name: "style mouse and cursor modes survive", | |||
| 1272 | + | run: func(t *testing.T) { | |||
| 1273 | + | s := NewScreen(5, 2) | |||
| 1274 | + | s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?25l\x1b[3 q")) | |||
| 1275 | + | ||||
| 1276 | + | s.Resize(6, 3) | |||
| 1277 | + | ||||
| 1278 | + | if !s.Style.Bold { | |||
| 1279 | + | t.Fatal("bold style was reset") | |||
| 1280 | + | } | |||
| 1281 | + | if mode, sgr := s.MouseMode(); mode != 1000 || !sgr { | |||
| 1282 | + | t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr) | |||
| 1283 | + | } | |||
| 1284 | + | if s.CursorVisible() { | |||
| 1285 | + | t.Fatal("cursor visibility was reset") | |||
| 1286 | + | } | |||
| 1287 | + | if style, ok := s.CursorStyle(); style != 3 || !ok { | |||
| 1288 | + | t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok) | |||
| 1127 | } | 1289 | } | ||
| 1128 | }, | 1290 | }, | ||
| 1129 | }, | 1291 | }, | ||