merge(feature): content-preserving resize and visual mode
16 files · +746 -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 +62 -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,70 @@ 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 | + | if ev.Type == mouse.Press { | |
| 99 | + | ac.copyMu.Lock() | |
| 100 | + | ac.copyPressRowValid = false | |
| 101 | + | ac.copyDragging = false | |
| 102 | + | ac.copyMu.Unlock() | |
| 103 | + | } | |
| 104 | + | tb.mu.Unlock() | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | ac.copyMu.Lock() | |
| 108 | + | if ac.copyMode == nil { | |
| 109 | + | ac.copyMu.Unlock() | |
| 110 | + | tb.mu.Unlock() | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 114 | + | absRow := ac.copyMode.ViewportTop + ev.Row | |
| 115 | + | changed := false | |
| 116 | + | switch ev.Type { | |
| 117 | + | case mouse.Press: | |
| 118 | + | ac.copyMode.SetCursor(snap, absRow) | |
| 119 | + | ac.copyPressRow = ac.copyMode.Cursor | |
| 120 | + | ac.copyPressRowValid = true | |
| 121 | + | ac.copyDragging = false | |
| 122 | + | changed = true | |
| 123 | + | case mouse.Motion: | |
| 124 | + | if !ac.copyPressRowValid { | |
| 125 | + | break | |
| 126 | + | } | |
| 127 | + | if !ac.copyDragging { | |
| 128 | + | ac.copyMode.StartSelectionAt(snap, ac.copyPressRow) | |
| 129 | + | ac.copyDragging = true | |
| 130 | + | } | |
| 131 | + | ac.copyMode.ExtendTo(snap, absRow) | |
| 132 | + | changed = true | |
| 133 | + | case mouse.Release: | |
| 134 | + | // Button release intentionally has no visual effect. | |
| 135 | + | } | |
| 136 | + | ac.copyMu.Unlock() | |
| 137 | + | tb.mu.Unlock() | |
| 138 | + | ||
| 139 | + | if changed { | |
| 140 | + | d.paint(sess, ac, true) | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 83 | 144 | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |
| 84 | 145 | sess := ac.currentSession() | |
| 85 | 146 | if sess == nil { | |
| @@ -125,7 +186,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 125 | 186 | case 'G': | |
| 126 | 187 | ac.copyMode.Bottom(snap) | |
| 127 | 188 | changed = true | |
| 128 | - | case ' ': | |
| 189 | + | case ' ', 'v': | |
| 129 | 190 | ac.copyMode.ToggleSelection() | |
| 130 | 191 | changed = true | |
| 131 | 192 | 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,70 @@ 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 | + | if ev.Type == mouse.Press { | |||
| 99 | + | ac.copyMu.Lock() | |||
| 100 | + | ac.copyPressRowValid = false | |||
| 101 | + | ac.copyDragging = false | |||
| 102 | + | ac.copyMu.Unlock() | |||
| 103 | + | } | |||
| 104 | + | tb.mu.Unlock() | |||
| 105 | + | return | |||
| 106 | + | } | |||
| 107 | + | ac.copyMu.Lock() | |||
| 108 | + | if ac.copyMode == nil { | |||
| 109 | + | ac.copyMu.Unlock() | |||
| 110 | + | tb.mu.Unlock() | |||
| 111 | + | return | |||
| 112 | + | } | |||
| 113 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |||
| 114 | + | absRow := ac.copyMode.ViewportTop + ev.Row | |||
| 115 | + | changed := false | |||
| 116 | + | switch ev.Type { | |||
| 117 | + | case mouse.Press: | |||
| 118 | + | ac.copyMode.SetCursor(snap, absRow) | |||
| 119 | + | ac.copyPressRow = ac.copyMode.Cursor | |||
| 120 | + | ac.copyPressRowValid = true | |||
| 121 | + | ac.copyDragging = false | |||
| 122 | + | changed = true | |||
| 123 | + | case mouse.Motion: | |||
| 124 | + | if !ac.copyPressRowValid { | |||
| 125 | + | break | |||
| 126 | + | } | |||
| 127 | + | if !ac.copyDragging { | |||
| 128 | + | ac.copyMode.StartSelectionAt(snap, ac.copyPressRow) | |||
| 129 | + | ac.copyDragging = true | |||
| 130 | + | } | |||
| 131 | + | ac.copyMode.ExtendTo(snap, absRow) | |||
| 132 | + | changed = true | |||
| 133 | + | case mouse.Release: | |||
| 134 | + | // Button release intentionally has no visual effect. | |||
| 135 | + | } | |||
| 136 | + | ac.copyMu.Unlock() | |||
| 137 | + | tb.mu.Unlock() | |||
| 138 | + | ||||
| 139 | + | if changed { | |||
| 140 | + | d.paint(sess, ac, true) | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 83 | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | 144 | func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | ||
| 84 | sess := ac.currentSession() | 145 | sess := ac.currentSession() | ||
| 85 | if sess == nil { | 146 | if sess == nil { | ||
| @@ -125,7 +186,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||||
| 125 | case 'G': | 186 | case 'G': | ||
| 126 | ac.copyMode.Bottom(snap) | 187 | ac.copyMode.Bottom(snap) | ||
| 127 | changed = true | 188 | changed = true | ||
| 128 | - | case ' ': | 189 | + | case ' ', 'v': |
| 129 | ac.copyMode.ToggleSelection() | 190 | ac.copyMode.ToggleSelection() | ||
| 130 | changed = true | 191 | changed = true | ||
| 131 | case '\r', '\n', 'y': | 192 | 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 +54 -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,53 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 93 | 100 | } | |
| 94 | 101 | ||
| 95 | 102 | switch ev.Button { | |
| 103 | + | case mouse.Left: | |
| 104 | + | switch ev.Type { | |
| 105 | + | case mouse.Press: | |
| 106 | + | if altScreen || ev.Row >= childRows { | |
| 107 | + | ac.copyMu.Lock() | |
| 108 | + | ac.normalMousePressValid = false | |
| 109 | + | ac.copyMu.Unlock() | |
| 110 | + | return | |
| 111 | + | } | |
| 112 | + | ac.copyMu.Lock() | |
| 113 | + | ac.normalMousePressRow = ev.Row | |
| 114 | + | ac.normalMousePressTop = scrollbackRows | |
| 115 | + | ac.normalMousePressValid = true | |
| 116 | + | ac.copyMu.Unlock() | |
| 117 | + | case mouse.Motion: | |
| 118 | + | if altScreen || ev.Row >= childRows { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | ac.copyMu.Lock() | |
| 122 | + | pressValid := ac.normalMousePressValid | |
| 123 | + | pressRow := ac.normalMousePressRow | |
| 124 | + | pressTop := ac.normalMousePressTop | |
| 125 | + | ac.copyMu.Unlock() | |
| 126 | + | if !pressValid { | |
| 127 | + | return | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | tb.mu.Lock() | |
| 131 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |
| 132 | + | tb.mu.Unlock() | |
| 133 | + | ||
| 134 | + | ac.copyMu.Lock() | |
| 135 | + | mode := scopy.NewMode(snap) | |
| 136 | + | mode.StartSelectionAt(snap, pressTop+pressRow) | |
| 137 | + | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | |
| 138 | + | ac.copyMode = mode | |
| 139 | + | ac.copyPressRow = pressTop + pressRow | |
| 140 | + | ac.copyPressRowValid = true | |
| 141 | + | ac.copyDragging = true | |
| 142 | + | ac.normalMousePressValid = false | |
| 143 | + | ac.copyMu.Unlock() | |
| 144 | + | d.paint(sess, ac, true) | |
| 145 | + | case mouse.Release: | |
| 146 | + | ac.copyMu.Lock() | |
| 147 | + | ac.normalMousePressValid = false | |
| 148 | + | ac.copyMu.Unlock() | |
| 149 | + | } | |
| 96 | 150 | case mouse.WheelUp: | |
| 97 | 151 | if altScreen { | |
| 98 | 152 | 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,53 @@ 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 | + | switch ev.Type { | |||
| 105 | + | case mouse.Press: | |||
| 106 | + | if altScreen || ev.Row >= childRows { | |||
| 107 | + | ac.copyMu.Lock() | |||
| 108 | + | ac.normalMousePressValid = false | |||
| 109 | + | ac.copyMu.Unlock() | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 112 | + | ac.copyMu.Lock() | |||
| 113 | + | ac.normalMousePressRow = ev.Row | |||
| 114 | + | ac.normalMousePressTop = scrollbackRows | |||
| 115 | + | ac.normalMousePressValid = true | |||
| 116 | + | ac.copyMu.Unlock() | |||
| 117 | + | case mouse.Motion: | |||
| 118 | + | if altScreen || ev.Row >= childRows { | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | ac.copyMu.Lock() | |||
| 122 | + | pressValid := ac.normalMousePressValid | |||
| 123 | + | pressRow := ac.normalMousePressRow | |||
| 124 | + | pressTop := ac.normalMousePressTop | |||
| 125 | + | ac.copyMu.Unlock() | |||
| 126 | + | if !pressValid { | |||
| 127 | + | return | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | tb.mu.Lock() | |||
| 131 | + | snap := scopy.NewSnapshot(tb.scrollback, tb.screen.Frame) | |||
| 132 | + | tb.mu.Unlock() | |||
| 133 | + | ||||
| 134 | + | ac.copyMu.Lock() | |||
| 135 | + | mode := scopy.NewMode(snap) | |||
| 136 | + | mode.StartSelectionAt(snap, pressTop+pressRow) | |||
| 137 | + | mode.ExtendTo(snap, len(snap.Rows)-snap.Height+ev.Row) | |||
| 138 | + | ac.copyMode = mode | |||
| 139 | + | ac.copyPressRow = pressTop + pressRow | |||
| 140 | + | ac.copyPressRowValid = true | |||
| 141 | + | ac.copyDragging = true | |||
| 142 | + | ac.normalMousePressValid = false | |||
| 143 | + | ac.copyMu.Unlock() | |||
| 144 | + | d.paint(sess, ac, true) | |||
| 145 | + | case mouse.Release: | |||
| 146 | + | ac.copyMu.Lock() | |||
| 147 | + | ac.normalMousePressValid = false | |||
| 148 | + | ac.copyMu.Unlock() | |||
| 149 | + | } | |||
| 96 | case mouse.WheelUp: | 150 | case mouse.WheelUp: | ||
| 97 | if altScreen { | 151 | if altScreen { | ||
| 98 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | 152 | daemonKeyHandler{d: d, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | ||
M internal/usecase/daemon/input_test.go +154 -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" | |
| @@ -9,6 +11,7 @@ import ( | |||
| 9 | 11 | ||
| 10 | 12 | "github.com/bnema/vev/internal/domain" | |
| 11 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 12 | 15 | ) | |
| 13 | 16 | ||
| 14 | 17 | // --- test doubles ----------------------------------------------------------- | |
| @@ -172,7 +175,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |||
| 172 | 175 | data := mustOutputData(t, sends) | |
| 173 | 176 | require.NotNil(t, ac.copyMode) | |
| 174 | 177 | require.Equal(t, 19, ac.copyMode.Cursor) | |
| 175 | - | require.Contains(t, string(data), "[SCROLL]") | |
| 178 | + | require.Contains(t, string(data), "[VISUAL]") | |
| 176 | 179 | ||
| 177 | 180 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 178 | 181 | mustOutputData(t, sends) | |
| @@ -206,7 +209,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||
| 206 | 209 | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |
| 207 | 210 | writes := make(chan []byte, 4) | |
| 208 | 211 | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 209 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 212 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 210 | 213 | ||
| 211 | 214 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 212 | 215 | select { | |
| @@ -214,6 +217,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 214 | 217 | t.Fatalf("press without child mouse mode forwarded: %q", got) | |
| 215 | 218 | default: | |
| 216 | 219 | } | |
| 220 | + | require.Nil(t, ac.copyMode, "press alone must not enter visual mode") | |
| 217 | 221 | ||
| 218 | 222 | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) | |
| 219 | 223 | raw := []byte("\x1b[<0;2;3M") | |
| @@ -234,6 +238,154 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 234 | 238 | t.Fatalf("status-row mouse report forwarded: %q", got) | |
| 235 | 239 | default: | |
| 236 | 240 | } | |
| 241 | + | ||
| 242 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | |
| 243 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 244 | + | require.Nil(t, ac.copyMode, "press alone must still not enter visual mode") | |
| 245 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 246 | + | mustOutputData(t, sends) | |
| 247 | + | require.NotNil(t, ac.copyMode) | |
| 248 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 249 | + | require.True(t, ok) | |
| 250 | + | require.Equal(t, 0, lo) | |
| 251 | + | require.Equal(t, 2, hi) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |
| 255 | + | p, _ := newBlockingPTY(t) | |
| 256 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 257 | + | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |
| 258 | + | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |
| 259 | + | copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie")) | |
| 260 | + | ||
| 261 | + | d.enterCopyMode(sess, ac) | |
| 262 | + | mustOutputData(t, sends) | |
| 263 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 264 | + | mustOutputData(t, sends) | |
| 265 | + | require.NotNil(t, ac.copyMode) | |
| 266 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 267 | + | require.True(t, ok) | |
| 268 | + | require.Equal(t, 0, lo) | |
| 269 | + | require.Equal(t, 1, hi) | |
| 270 | + | ||
| 271 | + | d.handleInput(sess, ac, []byte("y")) | |
| 272 | + | data := "" | |
| 273 | + | require.Eventually(t, func() bool { | |
| 274 | + | data = string(mustOutputData(t, sends)) | |
| 275 | + | return strings.HasPrefix(data, "\x1b]52;c;") | |
| 276 | + | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |
| 277 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | require.Equal(t, "alpha\nbravo", string(decoded)) | |
| 280 | + | require.Nil(t, ac.copyMode) | |
| 281 | + | ||
| 282 | + | exitPaint := string(mustOutputData(t, sends)) | |
| 283 | + | require.NotContains(t, exitPaint, "[SELECT]") | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // TestMouseNormalScreenStatusRowClearsStalePressState covers a regression | |
| 287 | + | // where a Press or Release landing on the status row (row >= childRows) | |
| 288 | + | // returned before the inner event-type switch ran, leaving | |
| 289 | + | // normalMousePressValid untouched. A later Motion on a content row would | |
| 290 | + | // then resurrect the stale anchor and start a selection out of thin air. | |
| 291 | + | // The fix requires Release to always clear the press state and Press on the | |
| 292 | + | // status row to clear it too, so a following Motion is a no-op. | |
| 293 | + | func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |
| 294 | + | p, _ := newBlockingPTY(t) | |
| 295 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 296 | + | require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 297 | + | ||
| 298 | + | // Press on a content row establishes a (soon to be stale) anchor. | |
| 299 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 300 | + | require.Nil(t, ac.copyMode) | |
| 301 | + | ||
| 302 | + | // Release lands on the status row: must still clear the press state, | |
| 303 | + | // regardless of the row it landed on. | |
| 304 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24m")) | |
| 305 | + | ||
| 306 | + | // A brand new button hold starts with a Press on the status row: must | |
| 307 | + | // clear (not silently preserve) any prior press state. | |
| 308 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |
| 309 | + | ||
| 310 | + | // Motion onto a content row must not resurrect a stale anchor. | |
| 311 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 312 | + | ||
| 313 | + | require.Nil(t, ac.copyMode, "stale press state must not resurrect a selection") | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart | |
| 317 | + | // of the same bug class: copyMouse ignored any event whose row landed on the | |
| 318 | + | // status row before updating copyPressRow/copyPressRowValid/copyDragging, so | |
| 319 | + | // a Press on the status row left the previous drag state in place and a | |
| 320 | + | // following Motion on a content row silently extended the old selection | |
| 321 | + | // instead of being a no-op. | |
| 322 | + | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |
| 323 | + | p, _ := newBlockingPTY(t) | |
| 324 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 325 | + | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |
| 326 | + | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |
| 327 | + | copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie")) | |
| 328 | + | require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 329 | + | ||
| 330 | + | d.enterCopyMode(sess, ac) | |
| 331 | + | mustOutputData(t, sends) | |
| 332 | + | ||
| 333 | + | // Full drag: press row0, motion to row1 -> selection [0,1]. | |
| 334 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |
| 335 | + | mustOutputData(t, sends) | |
| 336 | + | require.NotNil(t, ac.copyMode) | |
| 337 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 338 | + | require.True(t, ok) | |
| 339 | + | require.Equal(t, 0, lo) | |
| 340 | + | require.Equal(t, 1, hi) | |
| 341 | + | ||
| 342 | + | // Release, then a new button hold starting with Press on the status row. | |
| 343 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | |
| 344 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |
| 345 | + | ||
| 346 | + | // Motion onto a content row must be a no-op: the status-row press must | |
| 347 | + | // have cleared copyPressRowValid/copyDragging. | |
| 348 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 349 | + | ||
| 350 | + | require.NotNil(t, ac.copyMode) | |
| 351 | + | lo, hi, ok = ac.copyMode.SelectedBounds() | |
| 352 | + | require.True(t, ok) | |
| 353 | + | require.Equal(t, 0, lo, "anchor must not have moved") | |
| 354 | + | require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op") | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | // TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset covers a | |
| 358 | + | // regression where the first drag Motion extended the selection using the | |
| 359 | + | // scrollback length captured at Press time instead of the current | |
| 360 | + | // scrollback length, so if lines were evicted into scrollback between the | |
| 361 | + | // Press and the first Motion, the extend target landed on the wrong row. | |
| 362 | + | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |
| 363 | + | p, _ := newBlockingPTY(t) | |
| 364 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 365 | + | sess.tabs[0].scrollback = scopy.NewScrollback(50) | |
| 366 | + | require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24") | |
| 367 | + | ||
| 368 | + | // Press on a content row while scrollback is empty: the anchor is | |
| 369 | + | // content-stable at pressTop(0)+pressRow(0) = row 0. | |
| 370 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 371 | + | ||
| 372 | + | // Simulate 5 lines evicted into scrollback between the Press and the | |
| 373 | + | // first Motion (e.g. the child kept producing output). | |
| 374 | + | for i := 0; i < 5; i++ { | |
| 375 | + | sess.tabs[0].scrollback.Append(testRow("evicted")) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | |
| 379 | + | // The extend target must track the pointer's current content row: | |
| 380 | + | // current scrollback length (5) + ev.Row (2) = 7 -- not the stale | |
| 381 | + | // pressTop(0)+ev.Row(2) = 2. | |
| 382 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |
| 383 | + | ||
| 384 | + | require.NotNil(t, ac.copyMode) | |
| 385 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |
| 386 | + | require.True(t, ok) | |
| 387 | + | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | |
| 388 | + | require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset") | |
| 237 | 389 | } | |
| 238 | 390 | ||
| 239 | 391 | 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" | ||
| @@ -9,6 +11,7 @@ import ( | |||||
| 9 | 11 | ||||
| 10 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 11 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 12 | ) | 15 | ) | ||
| 13 | 16 | ||||
| 14 | // --- test doubles ----------------------------------------------------------- | 17 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -172,7 +175,7 @@ func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |||||
| 172 | data := mustOutputData(t, sends) | 175 | data := mustOutputData(t, sends) | ||
| 173 | require.NotNil(t, ac.copyMode) | 176 | require.NotNil(t, ac.copyMode) | ||
| 174 | require.Equal(t, 19, ac.copyMode.Cursor) | 177 | require.Equal(t, 19, ac.copyMode.Cursor) | ||
| 175 | - | require.Contains(t, string(data), "[SCROLL]") | 178 | + | require.Contains(t, string(data), "[VISUAL]") |
| 176 | 179 | ||||
| 177 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | 180 | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | ||
| 178 | mustOutputData(t, sends) | 181 | mustOutputData(t, sends) | ||
| @@ -206,7 +209,7 @@ func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||||
| 206 | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | 209 | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | ||
| 207 | writes := make(chan []byte, 4) | 210 | writes := make(chan []byte, 4) | ||
| 208 | p, _ := newBlockingPTYWithWrites(t, writes) | 211 | p, _ := newBlockingPTYWithWrites(t, writes) | ||
| 209 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | 212 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) |
| 210 | 213 | ||||
| 211 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | 214 | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | ||
| 212 | select { | 215 | select { | ||
| @@ -214,6 +217,7 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 214 | t.Fatalf("press without child mouse mode forwarded: %q", got) | 217 | t.Fatalf("press without child mouse mode forwarded: %q", got) | ||
| 215 | default: | 218 | default: | ||
| 216 | } | 219 | } | ||
| 220 | + | require.Nil(t, ac.copyMode, "press alone must not enter visual mode") | |||
| 217 | 221 | ||||
| 218 | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) | 222 | sess.tabs[0].screen.Write([]byte("\x1b[?1000h")) | ||
| 219 | raw := []byte("\x1b[<0;2;3M") | 223 | raw := []byte("\x1b[<0;2;3M") | ||
| @@ -234,6 +238,154 @@ func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||||
| 234 | t.Fatalf("status-row mouse report forwarded: %q", got) | 238 | t.Fatalf("status-row mouse report forwarded: %q", got) | ||
| 235 | default: | 239 | default: | ||
| 236 | } | 240 | } | ||
| 241 | + | ||||
| 242 | + | sess.tabs[0].screen.Write([]byte("\x1b[?1006l\x1b[?1000l")) | |||
| 243 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |||
| 244 | + | require.Nil(t, ac.copyMode, "press alone must still not enter visual mode") | |||
| 245 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |||
| 246 | + | mustOutputData(t, sends) | |||
| 247 | + | require.NotNil(t, ac.copyMode) | |||
| 248 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |||
| 249 | + | require.True(t, ok) | |||
| 250 | + | require.Equal(t, 0, lo) | |||
| 251 | + | require.Equal(t, 2, hi) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func TestCopyModeMouseDragYanksOSC52AndExits(t *testing.T) { | |||
| 255 | + | p, _ := newBlockingPTY(t) | |||
| 256 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 257 | + | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |||
| 258 | + | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |||
| 259 | + | copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie")) | |||
| 260 | + | ||||
| 261 | + | d.enterCopyMode(sess, ac) | |||
| 262 | + | mustOutputData(t, sends) | |||
| 263 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |||
| 264 | + | mustOutputData(t, sends) | |||
| 265 | + | require.NotNil(t, ac.copyMode) | |||
| 266 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |||
| 267 | + | require.True(t, ok) | |||
| 268 | + | require.Equal(t, 0, lo) | |||
| 269 | + | require.Equal(t, 1, hi) | |||
| 270 | + | ||||
| 271 | + | d.handleInput(sess, ac, []byte("y")) | |||
| 272 | + | data := "" | |||
| 273 | + | require.Eventually(t, func() bool { | |||
| 274 | + | data = string(mustOutputData(t, sends)) | |||
| 275 | + | return strings.HasPrefix(data, "\x1b]52;c;") | |||
| 276 | + | }, 2*time.Second, 5*time.Millisecond, "OSC52 output = %q", data) | |||
| 277 | + | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(data, "\x1b]52;c;"), "\a")) | |||
| 278 | + | require.NoError(t, err) | |||
| 279 | + | require.Equal(t, "alpha\nbravo", string(decoded)) | |||
| 280 | + | require.Nil(t, ac.copyMode) | |||
| 281 | + | ||||
| 282 | + | exitPaint := string(mustOutputData(t, sends)) | |||
| 283 | + | require.NotContains(t, exitPaint, "[SELECT]") | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // TestMouseNormalScreenStatusRowClearsStalePressState covers a regression | |||
| 287 | + | // where a Press or Release landing on the status row (row >= childRows) | |||
| 288 | + | // returned before the inner event-type switch ran, leaving | |||
| 289 | + | // normalMousePressValid untouched. A later Motion on a content row would | |||
| 290 | + | // then resurrect the stale anchor and start a selection out of thin air. | |||
| 291 | + | // The fix requires Release to always clear the press state and Press on the | |||
| 292 | + | // status row to clear it too, so a following Motion is a no-op. | |||
| 293 | + | func TestMouseNormalScreenStatusRowClearsStalePressState(t *testing.T) { | |||
| 294 | + | p, _ := newBlockingPTY(t) | |||
| 295 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 296 | + | require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24") | |||
| 297 | + | ||||
| 298 | + | // Press on a content row establishes a (soon to be stale) anchor. | |||
| 299 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |||
| 300 | + | require.Nil(t, ac.copyMode) | |||
| 301 | + | ||||
| 302 | + | // Release lands on the status row: must still clear the press state, | |||
| 303 | + | // regardless of the row it landed on. | |||
| 304 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24m")) | |||
| 305 | + | ||||
| 306 | + | // A brand new button hold starts with a Press on the status row: must | |||
| 307 | + | // clear (not silently preserve) any prior press state. | |||
| 308 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |||
| 309 | + | ||||
| 310 | + | // Motion onto a content row must not resurrect a stale anchor. | |||
| 311 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |||
| 312 | + | ||||
| 313 | + | require.Nil(t, ac.copyMode, "stale press state must not resurrect a selection") | |||
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // TestCopyModeStatusRowPressClearsDragState covers the copy-mode counterpart | |||
| 317 | + | // of the same bug class: copyMouse ignored any event whose row landed on the | |||
| 318 | + | // status row before updating copyPressRow/copyPressRowValid/copyDragging, so | |||
| 319 | + | // a Press on the status row left the previous drag state in place and a | |||
| 320 | + | // following Motion on a content row silently extended the old selection | |||
| 321 | + | // instead of being a no-op. | |||
| 322 | + | func TestCopyModeStatusRowPressClearsDragState(t *testing.T) { | |||
| 323 | + | p, _ := newBlockingPTY(t) | |||
| 324 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 325 | + | copy(sess.tabs[0].screen.Frame.Row(0), testRow("alpha")) | |||
| 326 | + | copy(sess.tabs[0].screen.Frame.Row(1), testRow("bravo")) | |||
| 327 | + | copy(sess.tabs[0].screen.Frame.Row(2), testRow("charlie")) | |||
| 328 | + | require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24") | |||
| 329 | + | ||||
| 330 | + | d.enterCopyMode(sess, ac) | |||
| 331 | + | mustOutputData(t, sends) | |||
| 332 | + | ||||
| 333 | + | // Full drag: press row0, motion to row1 -> selection [0,1]. | |||
| 334 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M\x1b[<32;1;2M")) | |||
| 335 | + | mustOutputData(t, sends) | |||
| 336 | + | require.NotNil(t, ac.copyMode) | |||
| 337 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |||
| 338 | + | require.True(t, ok) | |||
| 339 | + | require.Equal(t, 0, lo) | |||
| 340 | + | require.Equal(t, 1, hi) | |||
| 341 | + | ||||
| 342 | + | // Release, then a new button hold starting with Press on the status row. | |||
| 343 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;2m")) | |||
| 344 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |||
| 345 | + | ||||
| 346 | + | // Motion onto a content row must be a no-op: the status-row press must | |||
| 347 | + | // have cleared copyPressRowValid/copyDragging. | |||
| 348 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |||
| 349 | + | ||||
| 350 | + | require.NotNil(t, ac.copyMode) | |||
| 351 | + | lo, hi, ok = ac.copyMode.SelectedBounds() | |||
| 352 | + | require.True(t, ok) | |||
| 353 | + | require.Equal(t, 0, lo, "anchor must not have moved") | |||
| 354 | + | require.Equal(t, 1, hi, "status-row press must invalidate the drag so the motion is a no-op") | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | // TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset covers a | |||
| 358 | + | // regression where the first drag Motion extended the selection using the | |||
| 359 | + | // scrollback length captured at Press time instead of the current | |||
| 360 | + | // scrollback length, so if lines were evicted into scrollback between the | |||
| 361 | + | // Press and the first Motion, the extend target landed on the wrong row. | |||
| 362 | + | func TestMouseNormalScreenDragExtendsToCurrentScrollbackOffset(t *testing.T) { | |||
| 363 | + | p, _ := newBlockingPTY(t) | |||
| 364 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 365 | + | sess.tabs[0].scrollback = scopy.NewScrollback(50) | |||
| 366 | + | require.Equal(t, 23, sess.tabs[0].screen.Frame.Height, "fixture assumption: status row is wire row 24") | |||
| 367 | + | ||||
| 368 | + | // Press on a content row while scrollback is empty: the anchor is | |||
| 369 | + | // content-stable at pressTop(0)+pressRow(0) = row 0. | |||
| 370 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |||
| 371 | + | ||||
| 372 | + | // Simulate 5 lines evicted into scrollback between the Press and the | |||
| 373 | + | // first Motion (e.g. the child kept producing output). | |||
| 374 | + | for i := 0; i < 5; i++ { | |||
| 375 | + | sess.tabs[0].scrollback.Append(testRow("evicted")) | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | // Motion lands on wire row 3 (0-based row 2 of the *current* screen). | |||
| 379 | + | // The extend target must track the pointer's current content row: | |||
| 380 | + | // current scrollback length (5) + ev.Row (2) = 7 -- not the stale | |||
| 381 | + | // pressTop(0)+ev.Row(2) = 2. | |||
| 382 | + | d.handleInput(sess, ac, []byte("\x1b[<32;1;3M")) | |||
| 383 | + | ||||
| 384 | + | require.NotNil(t, ac.copyMode) | |||
| 385 | + | lo, hi, ok := ac.copyMode.SelectedBounds() | |||
| 386 | + | require.True(t, ok) | |||
| 387 | + | require.Equal(t, 0, lo, "anchor stays content-stable at the row under the pointer at press time") | |||
| 388 | + | require.Equal(t, 7, hi, "extend target must track the pointer's current content row, not a stale scrollback offset") | |||
| 237 | } | 389 | } | ||
| 238 | 390 | ||||
| 239 | func TestMouseSplitReportPreservesOrder(t *testing.T) { | 391 | 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 | }, | ||