feat(input): add mouse wheel and hardware cursor support
Track child cursor and mouse state, route SGR mouse input through copy-mode, child forwarding, and alternate-screen wheel behavior, and enable terminal mouse reporting while attached. Also guards the client sender against late queued frames after cancellation, fixing a shutdown-race test flake found during verification.
- commit
- 922d0fd3c33174dbbb4ed0a2ab489ec8657956a9
- author
- brice · · 2026-07-02 19:51 UTC
- parent
- c42d73a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +878 -21
M README.md +6 -0
| @@ -65,6 +65,12 @@ Copy mode freezes a view over the scrollback while the program keeps running und | |||
| 65 | 65 | ||
| 66 | 66 | Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. | |
| 67 | 67 | ||
| 68 | + | ## Mouse | |
| 69 | + | ||
| 70 | + | 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 copy mode, the wheel scrolls the copy-mode 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. | |
| 71 | + | ||
| 72 | + | 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. | |
| 73 | + | ||
| 68 | 74 | ## Development | |
| 69 | 75 | ||
| 70 | 76 | ```sh | |
| @@ -65,6 +65,12 @@ Copy mode freezes a view over the scrollback while the program keeps running und | |||||
| 65 | 65 | ||||
| 66 | Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. | 66 | Copying uses OSC 52, so the selection lands in your terminal's clipboard even across SSH. | ||
| 67 | 67 | ||||
| 68 | + | ## Mouse | |||
| 69 | + | ||||
| 70 | + | 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 copy mode, the wheel scrolls the copy-mode 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. | |||
| 71 | + | ||||
| 72 | + | 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. | |||
| 73 | + | ||||
| 68 | ## Development | 74 | ## Development | ||
| 69 | 75 | ||||
| 70 | ```sh | 76 | ```sh | ||
M internal/adapters/term/pty_integration_test.go +1 -1
| @@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 102 | 102 | ||
| 103 | 103 | select { | |
| 104 | 104 | case got := <-captured: | |
| 105 | - | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |
| 105 | + | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 106 | 106 | if string(got) != want { | |
| 107 | 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 108 | 108 | } | |
| @@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||||
| 102 | 102 | ||||
| 103 | select { | 103 | select { | ||
| 104 | case got := <-captured: | 104 | case got := <-captured: | ||
| 105 | - | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | 105 | + | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit |
| 106 | if string(got) != want { | 106 | if string(got) != want { | ||
| 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | 107 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 108 | } | 108 | } | ||
M internal/adapters/term/terminal.go +9 -6
| @@ -21,10 +21,13 @@ var _ ports.Terminal = (*Terminal)(nil) | |||
| 21 | 21 | // Escape sequences for alt-screen and cursor visibility control. All | |
| 22 | 22 | // emissions go through the batched writer and are explicitly flushed. | |
| 23 | 23 | const ( | |
| 24 | - | altScreenEnter = "\x1b[?1049h" | |
| 25 | - | altScreenExit = "\x1b[?1049l" | |
| 26 | - | cursorHide = "\x1b[?25l" | |
| 27 | - | cursorShow = "\x1b[?25h" | |
| 24 | + | altScreenEnter = "\x1b[?1049h" | |
| 25 | + | altScreenExit = "\x1b[?1049l" | |
| 26 | + | cursorHide = "\x1b[?25l" | |
| 27 | + | cursorShow = "\x1b[?25h" | |
| 28 | + | cursorStyleDefault = "\x1b[0 q" | |
| 29 | + | mouseEnable = "\x1b[?1002h\x1b[?1006h" | |
| 30 | + | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |
| 28 | 31 | ) | |
| 29 | 32 | ||
| 30 | 33 | // bufSize is the batched writer's buffer capacity. | |
| @@ -101,7 +104,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 101 | 104 | } | |
| 102 | 105 | t.orig = old | |
| 103 | 106 | ||
| 104 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil { | |
| 107 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil { | |
| 105 | 108 | _ = t.restoreRawLocked() | |
| 106 | 109 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 107 | 110 | } | |
| @@ -135,7 +138,7 @@ func (t *Terminal) restore() error { | |||
| 135 | 138 | t.mu.Lock() | |
| 136 | 139 | defer t.mu.Unlock() | |
| 137 | 140 | ||
| 138 | - | _, werr := t.bw.WriteString(cursorShow + altScreenExit) | |
| 141 | + | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit) | |
| 139 | 142 | ferr := t.bw.Flush() | |
| 140 | 143 | ||
| 141 | 144 | rerr := t.restoreRawLocked() | |
| @@ -21,10 +21,13 @@ var _ ports.Terminal = (*Terminal)(nil) | |||||
| 21 | // Escape sequences for alt-screen and cursor visibility control. All | 21 | // Escape sequences for alt-screen and cursor visibility control. All | ||
| 22 | // emissions go through the batched writer and are explicitly flushed. | 22 | // emissions go through the batched writer and are explicitly flushed. | ||
| 23 | const ( | 23 | const ( | ||
| 24 | - | altScreenEnter = "\x1b[?1049h" | 24 | + | altScreenEnter = "\x1b[?1049h" |
| 25 | - | altScreenExit = "\x1b[?1049l" | 25 | + | altScreenExit = "\x1b[?1049l" |
| 26 | - | cursorHide = "\x1b[?25l" | 26 | + | cursorHide = "\x1b[?25l" |
| 27 | - | cursorShow = "\x1b[?25h" | 27 | + | cursorShow = "\x1b[?25h" |
| 28 | + | cursorStyleDefault = "\x1b[0 q" | |||
| 29 | + | mouseEnable = "\x1b[?1002h\x1b[?1006h" | |||
| 30 | + | mouseDisable = "\x1b[?1002l\x1b[?1006l" | |||
| 28 | ) | 31 | ) | ||
| 29 | 32 | ||||
| 30 | // bufSize is the batched writer's buffer capacity. | 33 | // bufSize is the batched writer's buffer capacity. | ||
| @@ -101,7 +104,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||||
| 101 | } | 104 | } | ||
| 102 | t.orig = old | 105 | t.orig = old | ||
| 103 | 106 | ||||
| 104 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil { | 107 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable); err != nil { |
| 105 | _ = t.restoreRawLocked() | 108 | _ = t.restoreRawLocked() | ||
| 106 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | 109 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | ||
| 107 | } | 110 | } | ||
| @@ -135,7 +138,7 @@ func (t *Terminal) restore() error { | |||||
| 135 | t.mu.Lock() | 138 | t.mu.Lock() | ||
| 136 | defer t.mu.Unlock() | 139 | defer t.mu.Unlock() | ||
| 137 | 140 | ||||
| 138 | - | _, werr := t.bw.WriteString(cursorShow + altScreenExit) | 141 | + | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit) |
| 139 | ferr := t.bw.Flush() | 142 | ferr := t.bw.Flush() | ||
| 140 | 143 | ||||
| 141 | rerr := t.restoreRawLocked() | 144 | rerr := t.restoreRawLocked() | ||
M internal/adapters/term/terminal_test.go +2 -2
| @@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 60 | 60 | _ = outW.Close() | |
| 61 | 61 | <-done | |
| 62 | 62 | ||
| 63 | - | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |
| 63 | + | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 64 | 64 | if got := captured.String(); got != want { | |
| 65 | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 66 | 66 | } | |
| @@ -101,7 +101,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 101 | 101 | // Alt-screen/cursor escapes must appear exactly once for enter and | |
| 102 | 102 | // once for exit, regardless of how many times EnterRaw/restore were | |
| 103 | 103 | // called. | |
| 104 | - | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |
| 104 | + | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit | |
| 105 | 105 | if got := captured.String(); got != want { | |
| 106 | 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 107 | 107 | } | |
| @@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 60 | _ = outW.Close() | 60 | _ = outW.Close() | ||
| 61 | <-done | 61 | <-done | ||
| 62 | 62 | ||||
| 63 | - | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | 63 | + | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit |
| 64 | if got := captured.String(); got != want { | 64 | if got := captured.String(); got != want { | ||
| 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 66 | } | 66 | } | ||
| @@ -101,7 +101,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||||
| 101 | // Alt-screen/cursor escapes must appear exactly once for enter and | 101 | // Alt-screen/cursor escapes must appear exactly once for enter and | ||
| 102 | // once for exit, regardless of how many times EnterRaw/restore were | 102 | // once for exit, regardless of how many times EnterRaw/restore were | ||
| 103 | // called. | 103 | // called. | ||
| 104 | - | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | 104 | + | want := altScreenEnter + cursorHide + mouseEnable + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit |
| 105 | if got := captured.String(); got != want { | 105 | if got := captured.String(); got != want { | ||
| 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | 106 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 107 | } | 107 | } | ||
M internal/usecase/client/client.go +5 -0
| @@ -248,6 +248,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 248 | 248 | for { | |
| 249 | 249 | select { | |
| 250 | 250 | case f := <-in: | |
| 251 | + | select { | |
| 252 | + | case <-ctx.Done(): | |
| 253 | + | return | |
| 254 | + | default: | |
| 255 | + | } | |
| 251 | 256 | if err := transport.Send(f); err != nil { | |
| 252 | 257 | select { | |
| 253 | 258 | case errCh <- err: | |
| @@ -248,6 +248,11 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 248 | for { | 248 | for { | ||
| 249 | select { | 249 | select { | ||
| 250 | case f := <-in: | 250 | case f := <-in: | ||
| 251 | + | select { | |||
| 252 | + | case <-ctx.Done(): | |||
| 253 | + | return | |||
| 254 | + | default: | |||
| 255 | + | } | |||
| 251 | if err := transport.Send(f); err != nil { | 256 | if err := transport.Send(f); err != nil { | ||
| 252 | select { | 257 | select { | ||
| 253 | case errCh <- err: | 258 | case errCh <- err: | ||
M internal/usecase/copy/mode.go +8 -0
| @@ -60,6 +60,14 @@ func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s | |||
| 60 | 60 | func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) } | |
| 61 | 61 | func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) } | |
| 62 | 62 | ||
| 63 | + | func (m *Mode) AtBottom(s Snapshot) bool { | |
| 64 | + | total := len(s.Rows) | |
| 65 | + | if total == 0 { | |
| 66 | + | return m.Cursor == 0 && m.ViewportTop == 0 | |
| 67 | + | } | |
| 68 | + | return m.Cursor == total-1 && m.ViewportTop == max(total-max(s.Height, 1), 0) | |
| 69 | + | } | |
| 70 | + | ||
| 63 | 71 | func (m *Mode) ToggleSelection() { | |
| 64 | 72 | if m.Selecting { | |
| 65 | 73 | m.Selecting = false | |
| @@ -60,6 +60,14 @@ func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s | |||||
| 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 | 62 | ||||
| 63 | + | func (m *Mode) AtBottom(s Snapshot) bool { | |||
| 64 | + | total := len(s.Rows) | |||
| 65 | + | if total == 0 { | |||
| 66 | + | return m.Cursor == 0 && m.ViewportTop == 0 | |||
| 67 | + | } | |||
| 68 | + | return m.Cursor == total-1 && m.ViewportTop == max(total-max(s.Height, 1), 0) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 63 | func (m *Mode) ToggleSelection() { | 71 | func (m *Mode) ToggleSelection() { | ||
| 64 | if m.Selecting { | 72 | if m.Selecting { | ||
| 65 | m.Selecting = false | 73 | m.Selecting = false | ||
M internal/usecase/copy/mode_test.go +67 -0
| @@ -88,6 +88,47 @@ func TestCopyModeNavigationBounds(t *testing.T) { | |||
| 88 | 88 | } | |
| 89 | 89 | } | |
| 90 | 90 | ||
| 91 | + | func TestCopyModeAtBottom(t *testing.T) { | |
| 92 | + | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |
| 93 | + | m := NewMode(s) | |
| 94 | + | if !m.AtBottom(s) { | |
| 95 | + | t.Fatalf("AtBottom() after NewMode = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | m.Move(s, -1) | |
| 99 | + | if m.AtBottom(s) { | |
| 100 | + | t.Fatalf("AtBottom() after moving above bottom = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | m.Bottom(s) | |
| 104 | + | if !m.AtBottom(s) { | |
| 105 | + | t.Fatalf("AtBottom() after Bottom = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | m.Top(s) | |
| 109 | + | if m.AtBottom(s) { | |
| 110 | + | t.Fatalf("AtBottom() after Top = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) { | |
| 115 | + | s := snapshot([]string{"00", "01"}, 5) | |
| 116 | + | m := NewMode(s) | |
| 117 | + | if !m.AtBottom(s) { | |
| 118 | + | t.Fatalf("AtBottom() after NewMode on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | m.Top(s) | |
| 122 | + | if m.AtBottom(s) { | |
| 123 | + | t.Fatalf("AtBottom() after Top on short snapshot = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | m.Bottom(s) | |
| 127 | + | if !m.AtBottom(s) { | |
| 128 | + | t.Fatalf("AtBottom() after Bottom on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 91 | 132 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |
| 92 | 133 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |
| 93 | 134 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| @@ -104,6 +145,32 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |||
| 104 | 145 | } | |
| 105 | 146 | } | |
| 106 | 147 | ||
| 148 | + | func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) { | |
| 149 | + | s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3) | |
| 150 | + | m := NewMode(s) | |
| 151 | + | m.Move(s, -2) | |
| 152 | + | m.ToggleSelection() | |
| 153 | + | anchor := m.Anchor | |
| 154 | + | ||
| 155 | + | m.Move(s, 3) | |
| 156 | + | if m.Anchor != anchor { | |
| 157 | + | t.Fatalf("Anchor after Move(+3) while selecting = %d, want %d", m.Anchor, anchor) | |
| 158 | + | } | |
| 159 | + | lo, hi, ok := m.SelectedBounds() | |
| 160 | + | if !ok || lo != anchor || hi != m.Cursor { | |
| 161 | + | t.Fatalf("SelectedBounds after Move(+3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, anchor, m.Cursor) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | m.Move(s, -3) | |
| 165 | + | if m.Anchor != anchor { | |
| 166 | + | t.Fatalf("Anchor after Move(-3) while selecting = %d, want %d", m.Anchor, anchor) | |
| 167 | + | } | |
| 168 | + | lo, hi, ok = m.SelectedBounds() | |
| 169 | + | if !ok || lo != m.Cursor || hi != anchor { | |
| 170 | + | t.Fatalf("SelectedBounds after Move(-3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, m.Cursor, anchor) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 107 | 174 | func TestOSC52Base64Encoding(t *testing.T) { | |
| 108 | 175 | tests := []struct { | |
| 109 | 176 | name string | |
| @@ -88,6 +88,47 @@ func TestCopyModeNavigationBounds(t *testing.T) { | |||||
| 88 | } | 88 | } | ||
| 89 | } | 89 | } | ||
| 90 | 90 | ||||
| 91 | + | func TestCopyModeAtBottom(t *testing.T) { | |||
| 92 | + | s := snapshot([]string{"00", "01", "02", "03", "04"}, 3) | |||
| 93 | + | m := NewMode(s) | |||
| 94 | + | if !m.AtBottom(s) { | |||
| 95 | + | t.Fatalf("AtBottom() after NewMode = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | m.Move(s, -1) | |||
| 99 | + | if m.AtBottom(s) { | |||
| 100 | + | t.Fatalf("AtBottom() after moving above bottom = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | m.Bottom(s) | |||
| 104 | + | if !m.AtBottom(s) { | |||
| 105 | + | t.Fatalf("AtBottom() after Bottom = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | m.Top(s) | |||
| 109 | + | if m.AtBottom(s) { | |||
| 110 | + | t.Fatalf("AtBottom() after Top = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |||
| 111 | + | } | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestCopyModeAtBottomWithShortSnapshot(t *testing.T) { | |||
| 115 | + | s := snapshot([]string{"00", "01"}, 5) | |||
| 116 | + | m := NewMode(s) | |||
| 117 | + | if !m.AtBottom(s) { | |||
| 118 | + | t.Fatalf("AtBottom() after NewMode on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | m.Top(s) | |||
| 122 | + | if m.AtBottom(s) { | |||
| 123 | + | t.Fatalf("AtBottom() after Top on short snapshot = true, want false; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | m.Bottom(s) | |||
| 127 | + | if !m.AtBottom(s) { | |||
| 128 | + | t.Fatalf("AtBottom() after Bottom on short snapshot = false, want true; cursor/top=%d/%d", m.Cursor, m.ViewportTop) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 91 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | 132 | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | ||
| 92 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | 133 | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | ||
| 93 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | 134 | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | ||
| @@ -104,6 +145,32 @@ func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |||||
| 104 | } | 145 | } | ||
| 105 | } | 146 | } | ||
| 106 | 147 | ||||
| 148 | + | func TestCopyModeMoveWhileSelectingKeepsAnchorAndExtendsSelection(t *testing.T) { | |||
| 149 | + | s := snapshot([]string{"00", "01", "02", "03", "04", "05"}, 3) | |||
| 150 | + | m := NewMode(s) | |||
| 151 | + | m.Move(s, -2) | |||
| 152 | + | m.ToggleSelection() | |||
| 153 | + | anchor := m.Anchor | |||
| 154 | + | ||||
| 155 | + | m.Move(s, 3) | |||
| 156 | + | if m.Anchor != anchor { | |||
| 157 | + | t.Fatalf("Anchor after Move(+3) while selecting = %d, want %d", m.Anchor, anchor) | |||
| 158 | + | } | |||
| 159 | + | lo, hi, ok := m.SelectedBounds() | |||
| 160 | + | if !ok || lo != anchor || hi != m.Cursor { | |||
| 161 | + | t.Fatalf("SelectedBounds after Move(+3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, anchor, m.Cursor) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | m.Move(s, -3) | |||
| 165 | + | if m.Anchor != anchor { | |||
| 166 | + | t.Fatalf("Anchor after Move(-3) while selecting = %d, want %d", m.Anchor, anchor) | |||
| 167 | + | } | |||
| 168 | + | lo, hi, ok = m.SelectedBounds() | |||
| 169 | + | if !ok || lo != m.Cursor || hi != anchor { | |||
| 170 | + | t.Fatalf("SelectedBounds after Move(-3) = %d/%d/%v, want %d/%d/true", lo, hi, ok, m.Cursor, anchor) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | ||||
| 107 | func TestOSC52Base64Encoding(t *testing.T) { | 174 | func TestOSC52Base64Encoding(t *testing.T) { | ||
| 108 | tests := []struct { | 175 | tests := []struct { | ||
| 109 | name string | 176 | name string | ||
M internal/usecase/daemon/daemon.go +149 -6
| @@ -42,6 +42,7 @@ import ( | |||
| 42 | 42 | "github.com/bnema/vev/internal/ports" | |
| 43 | 43 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 44 | 44 | "github.com/bnema/vev/internal/usecase/keys" | |
| 45 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 45 | 46 | "github.com/bnema/vev/pkg/renderer" | |
| 46 | 47 | "github.com/bnema/vev/pkg/vt" | |
| 47 | 48 | ) | |
| @@ -160,9 +161,20 @@ type attachedClient struct { | |||
| 160 | 161 | copyMu sync.Mutex | |
| 161 | 162 | copyMode *scopy.Mode | |
| 162 | 163 | copyPending []byte | |
| 164 | + | mouseScan mouse.Scanner | |
| 165 | + | lastCursor cursorOut | |
| 163 | 166 | sendMu sync.Mutex | |
| 164 | 167 | } | |
| 165 | 168 | ||
| 169 | + | type cursorOut struct { | |
| 170 | + | valid bool | |
| 171 | + | hidden bool | |
| 172 | + | row int | |
| 173 | + | col int | |
| 174 | + | style int | |
| 175 | + | hasStyle bool | |
| 176 | + | } | |
| 177 | + | ||
| 166 | 178 | func (ac *attachedClient) copyModeActive() bool { | |
| 167 | 179 | ac.copyMu.Lock() | |
| 168 | 180 | defer ac.copyMu.Unlock() | |
| @@ -699,11 +711,93 @@ func (s *session) activeWindow() *window { | |||
| 699 | 711 | } | |
| 700 | 712 | ||
| 701 | 713 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |
| 714 | + | ac.mouseScan.Scan(data, | |
| 715 | + | func(ev mouse.Event) { d.handleMouse(sess, ac, ev) }, | |
| 716 | + | func(b []byte) { | |
| 717 | + | if ac.copyModeActive() { | |
| 718 | + | d.handleCopyInput(sess, ac, b) | |
| 719 | + | return | |
| 720 | + | } | |
| 721 | + | ac.keys.Route(b) | |
| 722 | + | }, | |
| 723 | + | ) | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |
| 727 | + | win := sess.activeWindow() | |
| 728 | + | if win == nil { | |
| 729 | + | return | |
| 730 | + | } | |
| 731 | + | ||
| 702 | 732 | if ac.copyModeActive() { | |
| 703 | - | d.handleCopyInput(sess, ac, data) | |
| 733 | + | switch ev.Button { | |
| 734 | + | case mouse.WheelUp: | |
| 735 | + | d.copyWheel(sess, ac, -3) | |
| 736 | + | case mouse.WheelDown: | |
| 737 | + | d.copyWheel(sess, ac, 3) | |
| 738 | + | } | |
| 739 | + | return | |
| 740 | + | } | |
| 741 | + | ||
| 742 | + | win.mu.Lock() | |
| 743 | + | childRows := win.screen.Frame.Height | |
| 744 | + | mouseMode, mouseSGR := win.screen.MouseMode() | |
| 745 | + | altScreen := win.screen.AltScreenActive() | |
| 746 | + | win.mu.Unlock() | |
| 747 | + | ||
| 748 | + | if mouseMode != 0 { | |
| 749 | + | if !mouseSGR || ev.Row >= childRows { | |
| 750 | + | return | |
| 751 | + | } | |
| 752 | + | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw) | |
| 753 | + | return | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | switch ev.Button { | |
| 757 | + | case mouse.WheelUp: | |
| 758 | + | if altScreen { | |
| 759 | + | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | |
| 760 | + | return | |
| 761 | + | } | |
| 762 | + | d.enterCopyMode(sess, ac) | |
| 763 | + | d.copyWheel(sess, ac, -3) | |
| 764 | + | case mouse.WheelDown: | |
| 765 | + | if altScreen { | |
| 766 | + | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B")) | |
| 767 | + | } | |
| 768 | + | } | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |
| 772 | + | win := sess.activeWindow() | |
| 773 | + | if win == nil { | |
| 774 | + | return | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | win.mu.Lock() | |
| 778 | + | ac.copyMu.Lock() | |
| 779 | + | if ac.copyMode == nil { | |
| 780 | + | ac.copyMu.Unlock() | |
| 781 | + | win.mu.Unlock() | |
| 704 | 782 | return | |
| 705 | 783 | } | |
| 706 | - | ac.keys.Route(data) | |
| 784 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 785 | + | if delta > 0 && ac.copyMode.AtBottom(snap) { | |
| 786 | + | ac.copyMode = nil | |
| 787 | + | ac.copyMu.Unlock() | |
| 788 | + | win.mu.Unlock() | |
| 789 | + | d.paint(sess, ac, true) | |
| 790 | + | return | |
| 791 | + | } | |
| 792 | + | ac.copyMode.Move(snap, delta) | |
| 793 | + | exit := delta > 0 && ac.copyMode.AtBottom(snap) | |
| 794 | + | if exit { | |
| 795 | + | ac.copyMode = nil | |
| 796 | + | } | |
| 797 | + | ac.copyMu.Unlock() | |
| 798 | + | win.mu.Unlock() | |
| 799 | + | ||
| 800 | + | d.paint(sess, ac, true) | |
| 707 | 801 | } | |
| 708 | 802 | ||
| 709 | 803 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| @@ -1150,21 +1244,33 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 1150 | 1244 | ac.sendMu.Lock() | |
| 1151 | 1245 | win.mu.Lock() | |
| 1152 | 1246 | ac.copyMu.Lock() | |
| 1153 | - | if reset || ac.copyMode != nil { | |
| 1247 | + | copyActive := ac.copyMode != nil | |
| 1248 | + | if reset || copyActive { | |
| 1154 | 1249 | ac.rend.Reset() | |
| 1155 | 1250 | } | |
| 1251 | + | if reset { | |
| 1252 | + | ac.lastCursor.valid = false | |
| 1253 | + | } | |
| 1156 | 1254 | frame, damage := composeClientFrame(sess, win, reset) | |
| 1157 | - | if ac.copyMode != nil { | |
| 1255 | + | if copyActive { | |
| 1158 | 1256 | frame, damage = composeCopyClientFrame(ac.copyMode, win) | |
| 1159 | 1257 | } | |
| 1258 | + | desiredCursor := desiredCursorOut(win.screen, copyActive) | |
| 1160 | 1259 | ac.copyMu.Unlock() | |
| 1161 | 1260 | data, err := ac.rend.Draw(frame, damage) | |
| 1261 | + | var cursorTail []byte | |
| 1262 | + | if err == nil { | |
| 1263 | + | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |
| 1264 | + | } | |
| 1162 | 1265 | win.screen.ClearDamage() | |
| 1163 | 1266 | win.mu.Unlock() | |
| 1164 | 1267 | ||
| 1165 | 1268 | var serr error | |
| 1166 | - | if err == nil && len(data) > 0 { | |
| 1167 | - | serr = ac.tr.Send(frameOutput(data)) | |
| 1269 | + | if err == nil { | |
| 1270 | + | data = append(data, cursorTail...) | |
| 1271 | + | if len(data) > 0 { | |
| 1272 | + | serr = ac.tr.Send(frameOutput(data)) | |
| 1273 | + | } | |
| 1168 | 1274 | } | |
| 1169 | 1275 | ac.sendMu.Unlock() | |
| 1170 | 1276 | ||
| @@ -1180,6 +1286,43 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 1180 | 1286 | // resize applies a client size change to every window using rows-1 for the PTY | |
| 1181 | 1287 | // and VT screen, then resets the renderer shadow and repaints the composed | |
| 1182 | 1288 | // client-sized frame (including the status row). | |
| 1289 | + | func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut { | |
| 1290 | + | if copyActive || !s.CursorVisible() { | |
| 1291 | + | return cursorOut{hidden: true} | |
| 1292 | + | } | |
| 1293 | + | style, ok := s.CursorStyle() | |
| 1294 | + | if !ok { | |
| 1295 | + | style = 5 | |
| 1296 | + | } | |
| 1297 | + | return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true} | |
| 1298 | + | } | |
| 1299 | + | ||
| 1300 | + | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |
| 1301 | + | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |
| 1302 | + | if !changed { | |
| 1303 | + | return nil | |
| 1304 | + | } | |
| 1305 | + | prev := ac.lastCursor | |
| 1306 | + | ac.lastCursor = desired | |
| 1307 | + | ac.lastCursor.valid = true | |
| 1308 | + | if desired.hidden { | |
| 1309 | + | return []byte("\x1b[?25l") | |
| 1310 | + | } | |
| 1311 | + | var b []byte | |
| 1312 | + | b = append(b, "\x1b["...) | |
| 1313 | + | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |
| 1314 | + | b = append(b, ';') | |
| 1315 | + | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |
| 1316 | + | b = append(b, 'H') | |
| 1317 | + | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |
| 1318 | + | b = append(b, "\x1b["...) | |
| 1319 | + | b = strconv.AppendInt(b, int64(desired.style), 10) | |
| 1320 | + | b = append(b, " q"...) | |
| 1321 | + | } | |
| 1322 | + | b = append(b, "\x1b[?25h"...) | |
| 1323 | + | return b | |
| 1324 | + | } | |
| 1325 | + | ||
| 1183 | 1326 | func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) { | |
| 1184 | 1327 | width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height | |
| 1185 | 1328 | frame := renderer.NewFrame(width, screenRows+1) | |
| @@ -42,6 +42,7 @@ import ( | |||||
| 42 | "github.com/bnema/vev/internal/ports" | 42 | "github.com/bnema/vev/internal/ports" | ||
| 43 | scopy "github.com/bnema/vev/internal/usecase/copy" | 43 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 44 | "github.com/bnema/vev/internal/usecase/keys" | 44 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 45 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 45 | "github.com/bnema/vev/pkg/renderer" | 46 | "github.com/bnema/vev/pkg/renderer" | ||
| 46 | "github.com/bnema/vev/pkg/vt" | 47 | "github.com/bnema/vev/pkg/vt" | ||
| 47 | ) | 48 | ) | ||
| @@ -160,9 +161,20 @@ type attachedClient struct { | |||||
| 160 | copyMu sync.Mutex | 161 | copyMu sync.Mutex | ||
| 161 | copyMode *scopy.Mode | 162 | copyMode *scopy.Mode | ||
| 162 | copyPending []byte | 163 | copyPending []byte | ||
| 164 | + | mouseScan mouse.Scanner | |||
| 165 | + | lastCursor cursorOut | |||
| 163 | sendMu sync.Mutex | 166 | sendMu sync.Mutex | ||
| 164 | } | 167 | } | ||
| 165 | 168 | ||||
| 169 | + | type cursorOut struct { | |||
| 170 | + | valid bool | |||
| 171 | + | hidden bool | |||
| 172 | + | row int | |||
| 173 | + | col int | |||
| 174 | + | style int | |||
| 175 | + | hasStyle bool | |||
| 176 | + | } | |||
| 177 | + | ||||
| 166 | func (ac *attachedClient) copyModeActive() bool { | 178 | func (ac *attachedClient) copyModeActive() bool { | ||
| 167 | ac.copyMu.Lock() | 179 | ac.copyMu.Lock() | ||
| 168 | defer ac.copyMu.Unlock() | 180 | defer ac.copyMu.Unlock() | ||
| @@ -699,11 +711,93 @@ func (s *session) activeWindow() *window { | |||||
| 699 | } | 711 | } | ||
| 700 | 712 | ||||
| 701 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | 713 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | ||
| 714 | + | ac.mouseScan.Scan(data, | |||
| 715 | + | func(ev mouse.Event) { d.handleMouse(sess, ac, ev) }, | |||
| 716 | + | func(b []byte) { | |||
| 717 | + | if ac.copyModeActive() { | |||
| 718 | + | d.handleCopyInput(sess, ac, b) | |||
| 719 | + | return | |||
| 720 | + | } | |||
| 721 | + | ac.keys.Route(b) | |||
| 722 | + | }, | |||
| 723 | + | ) | |||
| 724 | + | } | |||
| 725 | + | ||||
| 726 | + | func (d *Daemon) handleMouse(sess *session, ac *attachedClient, ev mouse.Event) { | |||
| 727 | + | win := sess.activeWindow() | |||
| 728 | + | if win == nil { | |||
| 729 | + | return | |||
| 730 | + | } | |||
| 731 | + | ||||
| 702 | if ac.copyModeActive() { | 732 | if ac.copyModeActive() { | ||
| 703 | - | d.handleCopyInput(sess, ac, data) | 733 | + | switch ev.Button { |
| 734 | + | case mouse.WheelUp: | |||
| 735 | + | d.copyWheel(sess, ac, -3) | |||
| 736 | + | case mouse.WheelDown: | |||
| 737 | + | d.copyWheel(sess, ac, 3) | |||
| 738 | + | } | |||
| 739 | + | return | |||
| 740 | + | } | |||
| 741 | + | ||||
| 742 | + | win.mu.Lock() | |||
| 743 | + | childRows := win.screen.Frame.Height | |||
| 744 | + | mouseMode, mouseSGR := win.screen.MouseMode() | |||
| 745 | + | altScreen := win.screen.AltScreenActive() | |||
| 746 | + | win.mu.Unlock() | |||
| 747 | + | ||||
| 748 | + | if mouseMode != 0 { | |||
| 749 | + | if !mouseSGR || ev.Row >= childRows { | |||
| 750 | + | return | |||
| 751 | + | } | |||
| 752 | + | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward(ev.Raw) | |||
| 753 | + | return | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | switch ev.Button { | |||
| 757 | + | case mouse.WheelUp: | |||
| 758 | + | if altScreen { | |||
| 759 | + | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[A\x1b[A\x1b[A")) | |||
| 760 | + | return | |||
| 761 | + | } | |||
| 762 | + | d.enterCopyMode(sess, ac) | |||
| 763 | + | d.copyWheel(sess, ac, -3) | |||
| 764 | + | case mouse.WheelDown: | |||
| 765 | + | if altScreen { | |||
| 766 | + | daemonKeyHandler{d: d, sess: sess, ac: ac}.Forward([]byte("\x1b[B\x1b[B\x1b[B")) | |||
| 767 | + | } | |||
| 768 | + | } | |||
| 769 | + | } | |||
| 770 | + | ||||
| 771 | + | func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) { | |||
| 772 | + | win := sess.activeWindow() | |||
| 773 | + | if win == nil { | |||
| 774 | + | return | |||
| 775 | + | } | |||
| 776 | + | ||||
| 777 | + | win.mu.Lock() | |||
| 778 | + | ac.copyMu.Lock() | |||
| 779 | + | if ac.copyMode == nil { | |||
| 780 | + | ac.copyMu.Unlock() | |||
| 781 | + | win.mu.Unlock() | |||
| 704 | return | 782 | return | ||
| 705 | } | 783 | } | ||
| 706 | - | ac.keys.Route(data) | 784 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) |
| 785 | + | if delta > 0 && ac.copyMode.AtBottom(snap) { | |||
| 786 | + | ac.copyMode = nil | |||
| 787 | + | ac.copyMu.Unlock() | |||
| 788 | + | win.mu.Unlock() | |||
| 789 | + | d.paint(sess, ac, true) | |||
| 790 | + | return | |||
| 791 | + | } | |||
| 792 | + | ac.copyMode.Move(snap, delta) | |||
| 793 | + | exit := delta > 0 && ac.copyMode.AtBottom(snap) | |||
| 794 | + | if exit { | |||
| 795 | + | ac.copyMode = nil | |||
| 796 | + | } | |||
| 797 | + | ac.copyMu.Unlock() | |||
| 798 | + | win.mu.Unlock() | |||
| 799 | + | ||||
| 800 | + | d.paint(sess, ac, true) | |||
| 707 | } | 801 | } | ||
| 708 | 802 | ||||
| 709 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | 803 | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | ||
| @@ -1150,21 +1244,33 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 1150 | ac.sendMu.Lock() | 1244 | ac.sendMu.Lock() | ||
| 1151 | win.mu.Lock() | 1245 | win.mu.Lock() | ||
| 1152 | ac.copyMu.Lock() | 1246 | ac.copyMu.Lock() | ||
| 1153 | - | if reset || ac.copyMode != nil { | 1247 | + | copyActive := ac.copyMode != nil |
| 1248 | + | if reset || copyActive { | |||
| 1154 | ac.rend.Reset() | 1249 | ac.rend.Reset() | ||
| 1155 | } | 1250 | } | ||
| 1251 | + | if reset { | |||
| 1252 | + | ac.lastCursor.valid = false | |||
| 1253 | + | } | |||
| 1156 | frame, damage := composeClientFrame(sess, win, reset) | 1254 | frame, damage := composeClientFrame(sess, win, reset) | ||
| 1157 | - | if ac.copyMode != nil { | 1255 | + | if copyActive { |
| 1158 | frame, damage = composeCopyClientFrame(ac.copyMode, win) | 1256 | frame, damage = composeCopyClientFrame(ac.copyMode, win) | ||
| 1159 | } | 1257 | } | ||
| 1258 | + | desiredCursor := desiredCursorOut(win.screen, copyActive) | |||
| 1160 | ac.copyMu.Unlock() | 1259 | ac.copyMu.Unlock() | ||
| 1161 | data, err := ac.rend.Draw(frame, damage) | 1260 | data, err := ac.rend.Draw(frame, damage) | ||
| 1261 | + | var cursorTail []byte | |||
| 1262 | + | if err == nil { | |||
| 1263 | + | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |||
| 1264 | + | } | |||
| 1162 | win.screen.ClearDamage() | 1265 | win.screen.ClearDamage() | ||
| 1163 | win.mu.Unlock() | 1266 | win.mu.Unlock() | ||
| 1164 | 1267 | ||||
| 1165 | var serr error | 1268 | var serr error | ||
| 1166 | - | if err == nil && len(data) > 0 { | 1269 | + | if err == nil { |
| 1167 | - | serr = ac.tr.Send(frameOutput(data)) | 1270 | + | data = append(data, cursorTail...) |
| 1271 | + | if len(data) > 0 { | |||
| 1272 | + | serr = ac.tr.Send(frameOutput(data)) | |||
| 1273 | + | } | |||
| 1168 | } | 1274 | } | ||
| 1169 | ac.sendMu.Unlock() | 1275 | ac.sendMu.Unlock() | ||
| 1170 | 1276 | ||||
| @@ -1180,6 +1286,43 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 1180 | // resize applies a client size change to every window using rows-1 for the PTY | 1286 | // resize applies a client size change to every window using rows-1 for the PTY | ||
| 1181 | // and VT screen, then resets the renderer shadow and repaints the composed | 1287 | // and VT screen, then resets the renderer shadow and repaints the composed | ||
| 1182 | // client-sized frame (including the status row). | 1288 | // client-sized frame (including the status row). | ||
| 1289 | + | func desiredCursorOut(s *vt.Screen, copyActive bool) cursorOut { | |||
| 1290 | + | if copyActive || !s.CursorVisible() { | |||
| 1291 | + | return cursorOut{hidden: true} | |||
| 1292 | + | } | |||
| 1293 | + | style, ok := s.CursorStyle() | |||
| 1294 | + | if !ok { | |||
| 1295 | + | style = 5 | |||
| 1296 | + | } | |||
| 1297 | + | return cursorOut{row: s.CursorRow(), col: s.CursorCol(), style: style, hasStyle: true} | |||
| 1298 | + | } | |||
| 1299 | + | ||||
| 1300 | + | func (ac *attachedClient) encodeCursorTail(desired cursorOut, force bool) []byte { | |||
| 1301 | + | changed := force || !ac.lastCursor.valid || ac.lastCursor.hidden != desired.hidden || ac.lastCursor.row != desired.row || ac.lastCursor.col != desired.col || ac.lastCursor.style != desired.style || ac.lastCursor.hasStyle != desired.hasStyle | |||
| 1302 | + | if !changed { | |||
| 1303 | + | return nil | |||
| 1304 | + | } | |||
| 1305 | + | prev := ac.lastCursor | |||
| 1306 | + | ac.lastCursor = desired | |||
| 1307 | + | ac.lastCursor.valid = true | |||
| 1308 | + | if desired.hidden { | |||
| 1309 | + | return []byte("\x1b[?25l") | |||
| 1310 | + | } | |||
| 1311 | + | var b []byte | |||
| 1312 | + | b = append(b, "\x1b["...) | |||
| 1313 | + | b = strconv.AppendInt(b, int64(desired.row+1), 10) | |||
| 1314 | + | b = append(b, ';') | |||
| 1315 | + | b = strconv.AppendInt(b, int64(desired.col+1), 10) | |||
| 1316 | + | b = append(b, 'H') | |||
| 1317 | + | if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle { | |||
| 1318 | + | b = append(b, "\x1b["...) | |||
| 1319 | + | b = strconv.AppendInt(b, int64(desired.style), 10) | |||
| 1320 | + | b = append(b, " q"...) | |||
| 1321 | + | } | |||
| 1322 | + | b = append(b, "\x1b[?25h"...) | |||
| 1323 | + | return b | |||
| 1324 | + | } | |||
| 1325 | + | ||||
| 1183 | func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) { | 1326 | func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, []renderer.Damage) { | ||
| 1184 | width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height | 1327 | width, screenRows := win.screen.Frame.Width, win.screen.Frame.Height | ||
| 1185 | frame := renderer.NewFrame(width, screenRows+1) | 1328 | frame := renderer.NewFrame(width, screenRows+1) | ||
M internal/usecase/daemon/daemon_test.go +134 -0
| @@ -1781,3 +1781,137 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||
| 1781 | 1781 | cancel() | |
| 1782 | 1782 | d.sessWg.Wait() | |
| 1783 | 1783 | } | |
| 1784 | + | ||
| 1785 | + | func mustOutputData(t *testing.T, sends chan ports.Frame) []byte { | |
| 1786 | + | t.Helper() | |
| 1787 | + | f := awaitFrame(t, sends, ports.MsgOutput) | |
| 1788 | + | out, err := ports.UnmarshalOutput(f.Payload) | |
| 1789 | + | require.NoError(t, err) | |
| 1790 | + | return out.Data | |
| 1791 | + | } | |
| 1792 | + | ||
| 1793 | + | func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) { | |
| 1794 | + | writes := make(chan []byte, 4) | |
| 1795 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 1796 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1797 | + | win := sess.windows[0] | |
| 1798 | + | win.screen.Write([]byte("live")) | |
| 1799 | + | ||
| 1800 | + | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 1801 | + | mustOutputData(t, sends) | |
| 1802 | + | require.NotNil(t, ac.copyMode) | |
| 1803 | + | require.Equal(t, 19, ac.copyMode.Cursor) | |
| 1804 | + | ||
| 1805 | + | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 1806 | + | mustOutputData(t, sends) | |
| 1807 | + | require.Nil(t, ac.copyMode) | |
| 1808 | + | ||
| 1809 | + | d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq")) | |
| 1810 | + | mustOutputData(t, sends) | |
| 1811 | + | mustOutputData(t, sends) | |
| 1812 | + | require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered") | |
| 1813 | + | select { | |
| 1814 | + | case got := <-writes: | |
| 1815 | + | t.Fatalf("mouse/copy input forwarded to PTY: %q", got) | |
| 1816 | + | default: | |
| 1817 | + | } | |
| 1818 | + | } | |
| 1819 | + | ||
| 1820 | + | func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |
| 1821 | + | writes := make(chan []byte, 2) | |
| 1822 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 1823 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1824 | + | sess.windows[0].screen.Write([]byte("\x1b[?1049h")) | |
| 1825 | + | ||
| 1826 | + | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |
| 1827 | + | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |
| 1828 | + | ||
| 1829 | + | require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes) | |
| 1830 | + | require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes) | |
| 1831 | + | require.Nil(t, ac.copyMode) | |
| 1832 | + | } | |
| 1833 | + | ||
| 1834 | + | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |
| 1835 | + | writes := make(chan []byte, 4) | |
| 1836 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 1837 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 1838 | + | ||
| 1839 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |
| 1840 | + | select { | |
| 1841 | + | case got := <-writes: | |
| 1842 | + | t.Fatalf("press without child mouse mode forwarded: %q", got) | |
| 1843 | + | default: | |
| 1844 | + | } | |
| 1845 | + | ||
| 1846 | + | sess.windows[0].screen.Write([]byte("\x1b[?1000h")) | |
| 1847 | + | raw := []byte("\x1b[<0;2;3M") | |
| 1848 | + | d.handleInput(sess, ac, raw) | |
| 1849 | + | select { | |
| 1850 | + | case got := <-writes: | |
| 1851 | + | t.Fatalf("SGR report forwarded to child without SGR mode: %q", got) | |
| 1852 | + | default: | |
| 1853 | + | } | |
| 1854 | + | ||
| 1855 | + | sess.windows[0].screen.Write([]byte("\x1b[?1006h")) | |
| 1856 | + | d.handleInput(sess, ac, raw) | |
| 1857 | + | require.Equal(t, raw, <-writes) | |
| 1858 | + | ||
| 1859 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |
| 1860 | + | select { | |
| 1861 | + | case got := <-writes: | |
| 1862 | + | t.Fatalf("status-row mouse report forwarded: %q", got) | |
| 1863 | + | default: | |
| 1864 | + | } | |
| 1865 | + | } | |
| 1866 | + | ||
| 1867 | + | func TestMouseSplitReportPreservesOrder(t *testing.T) { | |
| 1868 | + | writes := make(chan []byte, 2) | |
| 1869 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 1870 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1871 | + | sess.windows[0].screen.Write([]byte("live")) | |
| 1872 | + | ||
| 1873 | + | d.handleInput(sess, ac, []byte("\x1b[<64;")) | |
| 1874 | + | d.handleInput(sess, ac, []byte("1;1Mq")) | |
| 1875 | + | ||
| 1876 | + | mustOutputData(t, sends) | |
| 1877 | + | mustOutputData(t, sends) | |
| 1878 | + | require.Nil(t, ac.copyMode) | |
| 1879 | + | select { | |
| 1880 | + | case got := <-writes: | |
| 1881 | + | t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got) | |
| 1882 | + | default: | |
| 1883 | + | } | |
| 1884 | + | } | |
| 1885 | + | ||
| 1886 | + | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |
| 1887 | + | p, _ := newBlockingPTY(t) | |
| 1888 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1889 | + | win := sess.windows[0] | |
| 1890 | + | win.screen.Write([]byte("A")) | |
| 1891 | + | ||
| 1892 | + | d.paint(sess, ac, true) | |
| 1893 | + | data := mustOutputData(t, sends) | |
| 1894 | + | require.Contains(t, string(data), "\x1b[5 q") | |
| 1895 | + | require.Contains(t, string(data), "\x1b[?25h") | |
| 1896 | + | ||
| 1897 | + | win.screen.Write([]byte("\x1b[2;3H")) | |
| 1898 | + | d.paint(sess, ac, false) | |
| 1899 | + | data = mustOutputData(t, sends) | |
| 1900 | + | require.Contains(t, string(data), "\x1b[2;3H") | |
| 1901 | + | require.Contains(t, string(data), "\x1b[?25h") | |
| 1902 | + | ||
| 1903 | + | win.screen.Write([]byte("\x1b[?25l")) | |
| 1904 | + | d.paint(sess, ac, false) | |
| 1905 | + | data = mustOutputData(t, sends) | |
| 1906 | + | require.Contains(t, string(data), "\x1b[?25l") | |
| 1907 | + | } | |
| 1908 | + | ||
| 1909 | + | func TestCursorTailHidesInCopyMode(t *testing.T) { | |
| 1910 | + | p, _ := newBlockingPTY(t) | |
| 1911 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 1912 | + | sess.windows[0].screen.Write([]byte("live")) | |
| 1913 | + | ||
| 1914 | + | d.enterCopyMode(sess, ac) | |
| 1915 | + | data := mustOutputData(t, sends) | |
| 1916 | + | require.Contains(t, string(data), "\x1b[?25l") | |
| 1917 | + | } | |
| @@ -1781,3 +1781,137 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||||
| 1781 | cancel() | 1781 | cancel() | ||
| 1782 | d.sessWg.Wait() | 1782 | d.sessWg.Wait() | ||
| 1783 | } | 1783 | } | ||
| 1784 | + | ||||
| 1785 | + | func mustOutputData(t *testing.T, sends chan ports.Frame) []byte { | |||
| 1786 | + | t.Helper() | |||
| 1787 | + | f := awaitFrame(t, sends, ports.MsgOutput) | |||
| 1788 | + | out, err := ports.UnmarshalOutput(f.Payload) | |||
| 1789 | + | require.NoError(t, err) | |||
| 1790 | + | return out.Data | |||
| 1791 | + | } | |||
| 1792 | + | ||||
| 1793 | + | func TestMouseWheelCopyEntryExitAndStreamOrder(t *testing.T) { | |||
| 1794 | + | writes := make(chan []byte, 4) | |||
| 1795 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |||
| 1796 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1797 | + | win := sess.windows[0] | |||
| 1798 | + | win.screen.Write([]byte("live")) | |||
| 1799 | + | ||||
| 1800 | + | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |||
| 1801 | + | mustOutputData(t, sends) | |||
| 1802 | + | require.NotNil(t, ac.copyMode) | |||
| 1803 | + | require.Equal(t, 19, ac.copyMode.Cursor) | |||
| 1804 | + | ||||
| 1805 | + | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |||
| 1806 | + | mustOutputData(t, sends) | |||
| 1807 | + | require.Nil(t, ac.copyMode) | |||
| 1808 | + | ||||
| 1809 | + | d.handleInput(sess, ac, []byte("\x1b[<64;1;1Mq")) | |||
| 1810 | + | mustOutputData(t, sends) | |||
| 1811 | + | mustOutputData(t, sends) | |||
| 1812 | + | require.Nil(t, ac.copyMode, "q after wheel in same input must be routed after copy mode is entered") | |||
| 1813 | + | select { | |||
| 1814 | + | case got := <-writes: | |||
| 1815 | + | t.Fatalf("mouse/copy input forwarded to PTY: %q", got) | |||
| 1816 | + | default: | |||
| 1817 | + | } | |||
| 1818 | + | } | |||
| 1819 | + | ||||
| 1820 | + | func TestMouseAltScreenWheelMapsToArrows(t *testing.T) { | |||
| 1821 | + | writes := make(chan []byte, 2) | |||
| 1822 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |||
| 1823 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 1824 | + | sess.windows[0].screen.Write([]byte("\x1b[?1049h")) | |||
| 1825 | + | ||||
| 1826 | + | d.handleInput(sess, ac, []byte("\x1b[<64;1;1M")) | |||
| 1827 | + | d.handleInput(sess, ac, []byte("\x1b[<65;1;1M")) | |||
| 1828 | + | ||||
| 1829 | + | require.Equal(t, []byte("\x1b[A\x1b[A\x1b[A"), <-writes) | |||
| 1830 | + | require.Equal(t, []byte("\x1b[B\x1b[B\x1b[B"), <-writes) | |||
| 1831 | + | require.Nil(t, ac.copyMode) | |||
| 1832 | + | } | |||
| 1833 | + | ||||
| 1834 | + | func TestMouseChildForwardingStatusDropAndPressDrop(t *testing.T) { | |||
| 1835 | + | writes := make(chan []byte, 4) | |||
| 1836 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |||
| 1837 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 1838 | + | ||||
| 1839 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;1M")) | |||
| 1840 | + | select { | |||
| 1841 | + | case got := <-writes: | |||
| 1842 | + | t.Fatalf("press without child mouse mode forwarded: %q", got) | |||
| 1843 | + | default: | |||
| 1844 | + | } | |||
| 1845 | + | ||||
| 1846 | + | sess.windows[0].screen.Write([]byte("\x1b[?1000h")) | |||
| 1847 | + | raw := []byte("\x1b[<0;2;3M") | |||
| 1848 | + | d.handleInput(sess, ac, raw) | |||
| 1849 | + | select { | |||
| 1850 | + | case got := <-writes: | |||
| 1851 | + | t.Fatalf("SGR report forwarded to child without SGR mode: %q", got) | |||
| 1852 | + | default: | |||
| 1853 | + | } | |||
| 1854 | + | ||||
| 1855 | + | sess.windows[0].screen.Write([]byte("\x1b[?1006h")) | |||
| 1856 | + | d.handleInput(sess, ac, raw) | |||
| 1857 | + | require.Equal(t, raw, <-writes) | |||
| 1858 | + | ||||
| 1859 | + | d.handleInput(sess, ac, []byte("\x1b[<0;1;24M")) | |||
| 1860 | + | select { | |||
| 1861 | + | case got := <-writes: | |||
| 1862 | + | t.Fatalf("status-row mouse report forwarded: %q", got) | |||
| 1863 | + | default: | |||
| 1864 | + | } | |||
| 1865 | + | } | |||
| 1866 | + | ||||
| 1867 | + | func TestMouseSplitReportPreservesOrder(t *testing.T) { | |||
| 1868 | + | writes := make(chan []byte, 2) | |||
| 1869 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |||
| 1870 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1871 | + | sess.windows[0].screen.Write([]byte("live")) | |||
| 1872 | + | ||||
| 1873 | + | d.handleInput(sess, ac, []byte("\x1b[<64;")) | |||
| 1874 | + | d.handleInput(sess, ac, []byte("1;1Mq")) | |||
| 1875 | + | ||||
| 1876 | + | mustOutputData(t, sends) | |||
| 1877 | + | mustOutputData(t, sends) | |||
| 1878 | + | require.Nil(t, ac.copyMode) | |||
| 1879 | + | select { | |||
| 1880 | + | case got := <-writes: | |||
| 1881 | + | t.Fatalf("split mouse/copy bytes forwarded to PTY: %q", got) | |||
| 1882 | + | default: | |||
| 1883 | + | } | |||
| 1884 | + | } | |||
| 1885 | + | ||||
| 1886 | + | func TestCursorTailVisibleHideAndMoveOnly(t *testing.T) { | |||
| 1887 | + | p, _ := newBlockingPTY(t) | |||
| 1888 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1889 | + | win := sess.windows[0] | |||
| 1890 | + | win.screen.Write([]byte("A")) | |||
| 1891 | + | ||||
| 1892 | + | d.paint(sess, ac, true) | |||
| 1893 | + | data := mustOutputData(t, sends) | |||
| 1894 | + | require.Contains(t, string(data), "\x1b[5 q") | |||
| 1895 | + | require.Contains(t, string(data), "\x1b[?25h") | |||
| 1896 | + | ||||
| 1897 | + | win.screen.Write([]byte("\x1b[2;3H")) | |||
| 1898 | + | d.paint(sess, ac, false) | |||
| 1899 | + | data = mustOutputData(t, sends) | |||
| 1900 | + | require.Contains(t, string(data), "\x1b[2;3H") | |||
| 1901 | + | require.Contains(t, string(data), "\x1b[?25h") | |||
| 1902 | + | ||||
| 1903 | + | win.screen.Write([]byte("\x1b[?25l")) | |||
| 1904 | + | d.paint(sess, ac, false) | |||
| 1905 | + | data = mustOutputData(t, sends) | |||
| 1906 | + | require.Contains(t, string(data), "\x1b[?25l") | |||
| 1907 | + | } | |||
| 1908 | + | ||||
| 1909 | + | func TestCursorTailHidesInCopyMode(t *testing.T) { | |||
| 1910 | + | p, _ := newBlockingPTY(t) | |||
| 1911 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 1912 | + | sess.windows[0].screen.Write([]byte("live")) | |||
| 1913 | + | ||||
| 1914 | + | d.enterCopyMode(sess, ac) | |||
| 1915 | + | data := mustOutputData(t, sends) | |||
| 1916 | + | require.Contains(t, string(data), "\x1b[?25l") | |||
| 1917 | + | } | |||
A internal/usecase/mouse/mouse.go +186 -0
| @@ -0,0 +1,186 @@ | |||
| 1 | + | package mouse | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "strconv" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Type describes the kind of SGR mouse event. | |
| 9 | + | type Type int | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | Press Type = iota | |
| 13 | + | Release | |
| 14 | + | Motion | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Button describes the button encoded by an SGR mouse event. | |
| 18 | + | type Button int | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | Left Button = iota | |
| 22 | + | Middle | |
| 23 | + | Right | |
| 24 | + | WheelUp | |
| 25 | + | WheelDown | |
| 26 | + | Other | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // Event is a decoded SGR mouse report. | |
| 30 | + | type Event struct { | |
| 31 | + | Type Type | |
| 32 | + | Button Button | |
| 33 | + | Col int | |
| 34 | + | Row int | |
| 35 | + | Raw []byte | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Scanner decodes SGR mouse reports from a byte stream while preserving the | |
| 39 | + | // ordering of decoded mouse events and ordinary byte segments. | |
| 40 | + | type Scanner struct { | |
| 41 | + | pending []byte | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Scan decodes SGR mouse reports of the form ESC [ < Cb ; Cx ; Cy M/m. | |
| 45 | + | // Non-mouse bytes are emitted through onBytes in their original order. Only | |
| 46 | + | // partial reports after a complete ESC[< introducer are buffered between calls; | |
| 47 | + | // a bare ESC or ESC[ at the end of data is passed through unchanged. | |
| 48 | + | func (s *Scanner) Scan(data []byte, onMouse func(Event), onBytes func([]byte)) { | |
| 49 | + | if len(s.pending) > 0 { | |
| 50 | + | combined := make([]byte, 0, len(s.pending)+len(data)) | |
| 51 | + | combined = append(combined, s.pending...) | |
| 52 | + | combined = append(combined, data...) | |
| 53 | + | data = combined | |
| 54 | + | s.pending = nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | byteStart := 0 | |
| 58 | + | for i := 0; i < len(data); i++ { | |
| 59 | + | if data[i] != '\x1b' { | |
| 60 | + | continue | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | remaining := len(data) - i | |
| 64 | + | if remaining < 3 { | |
| 65 | + | continue | |
| 66 | + | } | |
| 67 | + | if data[i+1] != '[' || data[i+2] != '<' { | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | if byteStart < i { | |
| 72 | + | onBytes(data[byteStart:i]) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | raw, ev, complete, ok := parseSGR(data[i:]) | |
| 76 | + | if !complete { | |
| 77 | + | s.pending = append(s.pending, data[i:]...) | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | if !ok { | |
| 81 | + | onBytes(raw) | |
| 82 | + | i += len(raw) - 1 | |
| 83 | + | byteStart = i + 1 | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | onMouse(ev) | |
| 88 | + | i += len(raw) - 1 | |
| 89 | + | byteStart = i + 1 | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | if byteStart < len(data) { | |
| 93 | + | onBytes(data[byteStart:]) | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func parseSGR(data []byte) (raw []byte, ev Event, complete bool, ok bool) { | |
| 98 | + | end := -1 | |
| 99 | + | for i := 3; i < len(data); i++ { | |
| 100 | + | switch data[i] { | |
| 101 | + | case 'M', 'm': | |
| 102 | + | end = i | |
| 103 | + | goto found | |
| 104 | + | case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ';': | |
| 105 | + | continue | |
| 106 | + | default: | |
| 107 | + | return data[:i+1], Event{}, true, false | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | found: | |
| 111 | + | if end == -1 { | |
| 112 | + | return nil, Event{}, false, false | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | raw = data[:end+1] | |
| 116 | + | parts := bytes.Split(data[3:end], []byte(";")) | |
| 117 | + | if len(parts) != 3 { | |
| 118 | + | return raw, Event{}, true, false | |
| 119 | + | } | |
| 120 | + | cb, ok := atoiBytes(parts[0]) | |
| 121 | + | if !ok { | |
| 122 | + | return raw, Event{}, true, false | |
| 123 | + | } | |
| 124 | + | cx, ok := atoiBytes(parts[1]) | |
| 125 | + | if !ok { | |
| 126 | + | return raw, Event{}, true, false | |
| 127 | + | } | |
| 128 | + | cy, ok := atoiBytes(parts[2]) | |
| 129 | + | if !ok { | |
| 130 | + | return raw, Event{}, true, false | |
| 131 | + | } | |
| 132 | + | if cx < 1 || cy < 1 { | |
| 133 | + | return raw, Event{}, true, false | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | ev = Event{ | |
| 137 | + | Type: eventType(cb, data[end]), | |
| 138 | + | Button: eventButton(cb), | |
| 139 | + | Col: cx - 1, | |
| 140 | + | Row: cy - 1, | |
| 141 | + | Raw: append([]byte(nil), raw...), | |
| 142 | + | } | |
| 143 | + | return raw, ev, true, true | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func atoiBytes(b []byte) (int, bool) { | |
| 147 | + | if len(b) == 0 { | |
| 148 | + | return 0, false | |
| 149 | + | } | |
| 150 | + | v, err := strconv.Atoi(string(b)) | |
| 151 | + | return v, err == nil | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func eventType(cb int, final byte) Type { | |
| 155 | + | if final == 'm' { | |
| 156 | + | return Release | |
| 157 | + | } | |
| 158 | + | if cb&32 != 0 { | |
| 159 | + | return Motion | |
| 160 | + | } | |
| 161 | + | return Press | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func eventButton(cb int) Button { | |
| 165 | + | if cb&64 != 0 { | |
| 166 | + | switch cb { | |
| 167 | + | case 64: | |
| 168 | + | return WheelUp | |
| 169 | + | case 65: | |
| 170 | + | return WheelDown | |
| 171 | + | default: | |
| 172 | + | return Other | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | switch cb & 3 { | |
| 177 | + | case 0: | |
| 178 | + | return Left | |
| 179 | + | case 1: | |
| 180 | + | return Middle | |
| 181 | + | case 2: | |
| 182 | + | return Right | |
| 183 | + | default: | |
| 184 | + | return Other | |
| 185 | + | } | |
| 186 | + | } | |
| @@ -0,0 +1,186 @@ | |||||
| 1 | + | package mouse | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "strconv" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // Type describes the kind of SGR mouse event. | |||
| 9 | + | type Type int | |||
| 10 | + | ||||
| 11 | + | const ( | |||
| 12 | + | Press Type = iota | |||
| 13 | + | Release | |||
| 14 | + | Motion | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Button describes the button encoded by an SGR mouse event. | |||
| 18 | + | type Button int | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | Left Button = iota | |||
| 22 | + | Middle | |||
| 23 | + | Right | |||
| 24 | + | WheelUp | |||
| 25 | + | WheelDown | |||
| 26 | + | Other | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // Event is a decoded SGR mouse report. | |||
| 30 | + | type Event struct { | |||
| 31 | + | Type Type | |||
| 32 | + | Button Button | |||
| 33 | + | Col int | |||
| 34 | + | Row int | |||
| 35 | + | Raw []byte | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Scanner decodes SGR mouse reports from a byte stream while preserving the | |||
| 39 | + | // ordering of decoded mouse events and ordinary byte segments. | |||
| 40 | + | type Scanner struct { | |||
| 41 | + | pending []byte | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // Scan decodes SGR mouse reports of the form ESC [ < Cb ; Cx ; Cy M/m. | |||
| 45 | + | // Non-mouse bytes are emitted through onBytes in their original order. Only | |||
| 46 | + | // partial reports after a complete ESC[< introducer are buffered between calls; | |||
| 47 | + | // a bare ESC or ESC[ at the end of data is passed through unchanged. | |||
| 48 | + | func (s *Scanner) Scan(data []byte, onMouse func(Event), onBytes func([]byte)) { | |||
| 49 | + | if len(s.pending) > 0 { | |||
| 50 | + | combined := make([]byte, 0, len(s.pending)+len(data)) | |||
| 51 | + | combined = append(combined, s.pending...) | |||
| 52 | + | combined = append(combined, data...) | |||
| 53 | + | data = combined | |||
| 54 | + | s.pending = nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | byteStart := 0 | |||
| 58 | + | for i := 0; i < len(data); i++ { | |||
| 59 | + | if data[i] != '\x1b' { | |||
| 60 | + | continue | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | remaining := len(data) - i | |||
| 64 | + | if remaining < 3 { | |||
| 65 | + | continue | |||
| 66 | + | } | |||
| 67 | + | if data[i+1] != '[' || data[i+2] != '<' { | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | if byteStart < i { | |||
| 72 | + | onBytes(data[byteStart:i]) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | raw, ev, complete, ok := parseSGR(data[i:]) | |||
| 76 | + | if !complete { | |||
| 77 | + | s.pending = append(s.pending, data[i:]...) | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | if !ok { | |||
| 81 | + | onBytes(raw) | |||
| 82 | + | i += len(raw) - 1 | |||
| 83 | + | byteStart = i + 1 | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | onMouse(ev) | |||
| 88 | + | i += len(raw) - 1 | |||
| 89 | + | byteStart = i + 1 | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | if byteStart < len(data) { | |||
| 93 | + | onBytes(data[byteStart:]) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func parseSGR(data []byte) (raw []byte, ev Event, complete bool, ok bool) { | |||
| 98 | + | end := -1 | |||
| 99 | + | for i := 3; i < len(data); i++ { | |||
| 100 | + | switch data[i] { | |||
| 101 | + | case 'M', 'm': | |||
| 102 | + | end = i | |||
| 103 | + | goto found | |||
| 104 | + | case '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ';': | |||
| 105 | + | continue | |||
| 106 | + | default: | |||
| 107 | + | return data[:i+1], Event{}, true, false | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | found: | |||
| 111 | + | if end == -1 { | |||
| 112 | + | return nil, Event{}, false, false | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | raw = data[:end+1] | |||
| 116 | + | parts := bytes.Split(data[3:end], []byte(";")) | |||
| 117 | + | if len(parts) != 3 { | |||
| 118 | + | return raw, Event{}, true, false | |||
| 119 | + | } | |||
| 120 | + | cb, ok := atoiBytes(parts[0]) | |||
| 121 | + | if !ok { | |||
| 122 | + | return raw, Event{}, true, false | |||
| 123 | + | } | |||
| 124 | + | cx, ok := atoiBytes(parts[1]) | |||
| 125 | + | if !ok { | |||
| 126 | + | return raw, Event{}, true, false | |||
| 127 | + | } | |||
| 128 | + | cy, ok := atoiBytes(parts[2]) | |||
| 129 | + | if !ok { | |||
| 130 | + | return raw, Event{}, true, false | |||
| 131 | + | } | |||
| 132 | + | if cx < 1 || cy < 1 { | |||
| 133 | + | return raw, Event{}, true, false | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | ev = Event{ | |||
| 137 | + | Type: eventType(cb, data[end]), | |||
| 138 | + | Button: eventButton(cb), | |||
| 139 | + | Col: cx - 1, | |||
| 140 | + | Row: cy - 1, | |||
| 141 | + | Raw: append([]byte(nil), raw...), | |||
| 142 | + | } | |||
| 143 | + | return raw, ev, true, true | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func atoiBytes(b []byte) (int, bool) { | |||
| 147 | + | if len(b) == 0 { | |||
| 148 | + | return 0, false | |||
| 149 | + | } | |||
| 150 | + | v, err := strconv.Atoi(string(b)) | |||
| 151 | + | return v, err == nil | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func eventType(cb int, final byte) Type { | |||
| 155 | + | if final == 'm' { | |||
| 156 | + | return Release | |||
| 157 | + | } | |||
| 158 | + | if cb&32 != 0 { | |||
| 159 | + | return Motion | |||
| 160 | + | } | |||
| 161 | + | return Press | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func eventButton(cb int) Button { | |||
| 165 | + | if cb&64 != 0 { | |||
| 166 | + | switch cb { | |||
| 167 | + | case 64: | |||
| 168 | + | return WheelUp | |||
| 169 | + | case 65: | |||
| 170 | + | return WheelDown | |||
| 171 | + | default: | |||
| 172 | + | return Other | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | switch cb & 3 { | |||
| 177 | + | case 0: | |||
| 178 | + | return Left | |||
| 179 | + | case 1: | |||
| 180 | + | return Middle | |||
| 181 | + | case 2: | |||
| 182 | + | return Right | |||
| 183 | + | default: | |||
| 184 | + | return Other | |||
| 185 | + | } | |||
| 186 | + | } | |||
A internal/usecase/mouse/mouse_test.go +145 -0
| @@ -0,0 +1,145 @@ | |||
| 1 | + | package mouse | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "reflect" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type scanToken struct { | |
| 10 | + | kind string | |
| 11 | + | data []byte | |
| 12 | + | event Event | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func collectScan(s *Scanner, chunks ...[]byte) []scanToken { | |
| 16 | + | var tokens []scanToken | |
| 17 | + | for _, chunk := range chunks { | |
| 18 | + | s.Scan(chunk, func(ev Event) { | |
| 19 | + | tokens = append(tokens, scanToken{kind: "mouse", event: ev}) | |
| 20 | + | }, func(b []byte) { | |
| 21 | + | tokens = append(tokens, scanToken{kind: "bytes", data: append([]byte(nil), b...)}) | |
| 22 | + | }) | |
| 23 | + | } | |
| 24 | + | return tokens | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func TestScanDecodesWheelClickAndDrag(t *testing.T) { | |
| 28 | + | tests := []struct { | |
| 29 | + | name string | |
| 30 | + | in []byte | |
| 31 | + | want Event | |
| 32 | + | }{ | |
| 33 | + | { | |
| 34 | + | name: "wheel up", | |
| 35 | + | in: []byte("\x1b[<64;10;20M"), | |
| 36 | + | want: Event{Type: Press, Button: WheelUp, Col: 9, Row: 19, Raw: []byte("\x1b[<64;10;20M")}, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "wheel down", | |
| 40 | + | in: []byte("\x1b[<65;3;4M"), | |
| 41 | + | want: Event{Type: Press, Button: WheelDown, Col: 2, Row: 3, Raw: []byte("\x1b[<65;3;4M")}, | |
| 42 | + | }, | |
| 43 | + | { | |
| 44 | + | name: "left press", | |
| 45 | + | in: []byte("\x1b[<0;1;2M"), | |
| 46 | + | want: Event{Type: Press, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2M")}, | |
| 47 | + | }, | |
| 48 | + | { | |
| 49 | + | name: "left release", | |
| 50 | + | in: []byte("\x1b[<0;1;2m"), | |
| 51 | + | want: Event{Type: Release, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2m")}, | |
| 52 | + | }, | |
| 53 | + | { | |
| 54 | + | name: "right motion", | |
| 55 | + | in: []byte("\x1b[<34;7;8M"), | |
| 56 | + | want: Event{Type: Motion, Button: Right, Col: 6, Row: 7, Raw: []byte("\x1b[<34;7;8M")}, | |
| 57 | + | }, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | for _, tt := range tests { | |
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |
| 62 | + | tokens := collectScan(&Scanner{}, tt.in) | |
| 63 | + | if len(tokens) != 1 || tokens[0].kind != "mouse" { | |
| 64 | + | t.Fatalf("tokens = %#v, want one mouse token", tokens) | |
| 65 | + | } | |
| 66 | + | if !reflect.DeepEqual(tokens[0].event, tt.want) { | |
| 67 | + | t.Fatalf("event = %#v, want %#v", tokens[0].event, tt.want) | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestScanCoalescedEvents(t *testing.T) { | |
| 74 | + | tokens := collectScan(&Scanner{}, []byte("\x1b[<0;1;1M\x1b[<65;2;3M")) | |
| 75 | + | if len(tokens) != 2 || tokens[0].kind != "mouse" || tokens[1].kind != "mouse" { | |
| 76 | + | t.Fatalf("tokens = %#v, want two mouse tokens", tokens) | |
| 77 | + | } | |
| 78 | + | if tokens[0].event.Button != Left || tokens[1].event.Button != WheelDown { | |
| 79 | + | t.Fatalf("buttons = %v, %v; want Left, WheelDown", tokens[0].event.Button, tokens[1].event.Button) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestScanBuffersSplitReportAfterIntroducer(t *testing.T) { | |
| 84 | + | var s Scanner | |
| 85 | + | first := collectScan(&s, []byte("\x1b[<64;")) | |
| 86 | + | if len(first) != 0 { | |
| 87 | + | t.Fatalf("first scan tokens = %#v, want none while pending", first) | |
| 88 | + | } | |
| 89 | + | second := collectScan(&s, []byte("5;6M")) | |
| 90 | + | if len(second) != 1 || second[0].kind != "mouse" { | |
| 91 | + | t.Fatalf("second scan tokens = %#v, want one mouse token", second) | |
| 92 | + | } | |
| 93 | + | want := Event{Type: Press, Button: WheelUp, Col: 4, Row: 5, Raw: []byte("\x1b[<64;5;6M")} | |
| 94 | + | if !reflect.DeepEqual(second[0].event, want) { | |
| 95 | + | t.Fatalf("event = %#v, want %#v", second[0].event, want) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestScanPreservesInterleavedOrdering(t *testing.T) { | |
| 100 | + | tokens := collectScan(&Scanner{}, []byte("a\x1b[<0;1;1Mb\x1b[<65;2;2Mc")) | |
| 101 | + | want := []scanToken{ | |
| 102 | + | {kind: "bytes", data: []byte("a")}, | |
| 103 | + | {kind: "mouse", event: Event{Type: Press, Button: Left, Col: 0, Row: 0, Raw: []byte("\x1b[<0;1;1M")}}, | |
| 104 | + | {kind: "bytes", data: []byte("b")}, | |
| 105 | + | {kind: "mouse", event: Event{Type: Press, Button: WheelDown, Col: 1, Row: 1, Raw: []byte("\x1b[<65;2;2M")}}, | |
| 106 | + | {kind: "bytes", data: []byte("c")}, | |
| 107 | + | } | |
| 108 | + | if !reflect.DeepEqual(tokens, want) { | |
| 109 | + | t.Fatalf("tokens = %#v, want %#v", tokens, want) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestScanMalformedBailsToBytes(t *testing.T) { | |
| 114 | + | tests := [][]byte{ | |
| 115 | + | []byte("\x1b[<x"), | |
| 116 | + | []byte("\x1b[<0;;1M"), | |
| 117 | + | []byte("\x1b[<0;0;1M"), | |
| 118 | + | []byte("\x1b[<0;1M"), | |
| 119 | + | } | |
| 120 | + | for _, in := range tests { | |
| 121 | + | t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) { | |
| 122 | + | tokens := collectScan(&Scanner{}, in) | |
| 123 | + | if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) { | |
| 124 | + | t.Fatalf("tokens = %#v, want one byte token %q", tokens, in) | |
| 125 | + | } | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestScanPassesThroughBareESCAndESCBracket(t *testing.T) { | |
| 131 | + | tests := [][]byte{ | |
| 132 | + | []byte("\x1b"), | |
| 133 | + | []byte("\x1b["), | |
| 134 | + | []byte("x\x1b"), | |
| 135 | + | []byte("x\x1b["), | |
| 136 | + | } | |
| 137 | + | for _, in := range tests { | |
| 138 | + | t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) { | |
| 139 | + | tokens := collectScan(&Scanner{}, in) | |
| 140 | + | if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) { | |
| 141 | + | t.Fatalf("tokens = %#v, want passthrough %q", tokens, in) | |
| 142 | + | } | |
| 143 | + | }) | |
| 144 | + | } | |
| 145 | + | } | |
| @@ -0,0 +1,145 @@ | |||||
| 1 | + | package mouse | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "reflect" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type scanToken struct { | |||
| 10 | + | kind string | |||
| 11 | + | data []byte | |||
| 12 | + | event Event | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func collectScan(s *Scanner, chunks ...[]byte) []scanToken { | |||
| 16 | + | var tokens []scanToken | |||
| 17 | + | for _, chunk := range chunks { | |||
| 18 | + | s.Scan(chunk, func(ev Event) { | |||
| 19 | + | tokens = append(tokens, scanToken{kind: "mouse", event: ev}) | |||
| 20 | + | }, func(b []byte) { | |||
| 21 | + | tokens = append(tokens, scanToken{kind: "bytes", data: append([]byte(nil), b...)}) | |||
| 22 | + | }) | |||
| 23 | + | } | |||
| 24 | + | return tokens | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestScanDecodesWheelClickAndDrag(t *testing.T) { | |||
| 28 | + | tests := []struct { | |||
| 29 | + | name string | |||
| 30 | + | in []byte | |||
| 31 | + | want Event | |||
| 32 | + | }{ | |||
| 33 | + | { | |||
| 34 | + | name: "wheel up", | |||
| 35 | + | in: []byte("\x1b[<64;10;20M"), | |||
| 36 | + | want: Event{Type: Press, Button: WheelUp, Col: 9, Row: 19, Raw: []byte("\x1b[<64;10;20M")}, | |||
| 37 | + | }, | |||
| 38 | + | { | |||
| 39 | + | name: "wheel down", | |||
| 40 | + | in: []byte("\x1b[<65;3;4M"), | |||
| 41 | + | want: Event{Type: Press, Button: WheelDown, Col: 2, Row: 3, Raw: []byte("\x1b[<65;3;4M")}, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | name: "left press", | |||
| 45 | + | in: []byte("\x1b[<0;1;2M"), | |||
| 46 | + | want: Event{Type: Press, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2M")}, | |||
| 47 | + | }, | |||
| 48 | + | { | |||
| 49 | + | name: "left release", | |||
| 50 | + | in: []byte("\x1b[<0;1;2m"), | |||
| 51 | + | want: Event{Type: Release, Button: Left, Col: 0, Row: 1, Raw: []byte("\x1b[<0;1;2m")}, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "right motion", | |||
| 55 | + | in: []byte("\x1b[<34;7;8M"), | |||
| 56 | + | want: Event{Type: Motion, Button: Right, Col: 6, Row: 7, Raw: []byte("\x1b[<34;7;8M")}, | |||
| 57 | + | }, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | for _, tt := range tests { | |||
| 61 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 62 | + | tokens := collectScan(&Scanner{}, tt.in) | |||
| 63 | + | if len(tokens) != 1 || tokens[0].kind != "mouse" { | |||
| 64 | + | t.Fatalf("tokens = %#v, want one mouse token", tokens) | |||
| 65 | + | } | |||
| 66 | + | if !reflect.DeepEqual(tokens[0].event, tt.want) { | |||
| 67 | + | t.Fatalf("event = %#v, want %#v", tokens[0].event, tt.want) | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestScanCoalescedEvents(t *testing.T) { | |||
| 74 | + | tokens := collectScan(&Scanner{}, []byte("\x1b[<0;1;1M\x1b[<65;2;3M")) | |||
| 75 | + | if len(tokens) != 2 || tokens[0].kind != "mouse" || tokens[1].kind != "mouse" { | |||
| 76 | + | t.Fatalf("tokens = %#v, want two mouse tokens", tokens) | |||
| 77 | + | } | |||
| 78 | + | if tokens[0].event.Button != Left || tokens[1].event.Button != WheelDown { | |||
| 79 | + | t.Fatalf("buttons = %v, %v; want Left, WheelDown", tokens[0].event.Button, tokens[1].event.Button) | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestScanBuffersSplitReportAfterIntroducer(t *testing.T) { | |||
| 84 | + | var s Scanner | |||
| 85 | + | first := collectScan(&s, []byte("\x1b[<64;")) | |||
| 86 | + | if len(first) != 0 { | |||
| 87 | + | t.Fatalf("first scan tokens = %#v, want none while pending", first) | |||
| 88 | + | } | |||
| 89 | + | second := collectScan(&s, []byte("5;6M")) | |||
| 90 | + | if len(second) != 1 || second[0].kind != "mouse" { | |||
| 91 | + | t.Fatalf("second scan tokens = %#v, want one mouse token", second) | |||
| 92 | + | } | |||
| 93 | + | want := Event{Type: Press, Button: WheelUp, Col: 4, Row: 5, Raw: []byte("\x1b[<64;5;6M")} | |||
| 94 | + | if !reflect.DeepEqual(second[0].event, want) { | |||
| 95 | + | t.Fatalf("event = %#v, want %#v", second[0].event, want) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestScanPreservesInterleavedOrdering(t *testing.T) { | |||
| 100 | + | tokens := collectScan(&Scanner{}, []byte("a\x1b[<0;1;1Mb\x1b[<65;2;2Mc")) | |||
| 101 | + | want := []scanToken{ | |||
| 102 | + | {kind: "bytes", data: []byte("a")}, | |||
| 103 | + | {kind: "mouse", event: Event{Type: Press, Button: Left, Col: 0, Row: 0, Raw: []byte("\x1b[<0;1;1M")}}, | |||
| 104 | + | {kind: "bytes", data: []byte("b")}, | |||
| 105 | + | {kind: "mouse", event: Event{Type: Press, Button: WheelDown, Col: 1, Row: 1, Raw: []byte("\x1b[<65;2;2M")}}, | |||
| 106 | + | {kind: "bytes", data: []byte("c")}, | |||
| 107 | + | } | |||
| 108 | + | if !reflect.DeepEqual(tokens, want) { | |||
| 109 | + | t.Fatalf("tokens = %#v, want %#v", tokens, want) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestScanMalformedBailsToBytes(t *testing.T) { | |||
| 114 | + | tests := [][]byte{ | |||
| 115 | + | []byte("\x1b[<x"), | |||
| 116 | + | []byte("\x1b[<0;;1M"), | |||
| 117 | + | []byte("\x1b[<0;0;1M"), | |||
| 118 | + | []byte("\x1b[<0;1M"), | |||
| 119 | + | } | |||
| 120 | + | for _, in := range tests { | |||
| 121 | + | t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) { | |||
| 122 | + | tokens := collectScan(&Scanner{}, in) | |||
| 123 | + | if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) { | |||
| 124 | + | t.Fatalf("tokens = %#v, want one byte token %q", tokens, in) | |||
| 125 | + | } | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestScanPassesThroughBareESCAndESCBracket(t *testing.T) { | |||
| 131 | + | tests := [][]byte{ | |||
| 132 | + | []byte("\x1b"), | |||
| 133 | + | []byte("\x1b["), | |||
| 134 | + | []byte("x\x1b"), | |||
| 135 | + | []byte("x\x1b["), | |||
| 136 | + | } | |||
| 137 | + | for _, in := range tests { | |||
| 138 | + | t.Run(string(bytes.ReplaceAll(in, []byte("\x1b"), []byte("ESC"))), func(t *testing.T) { | |||
| 139 | + | tokens := collectScan(&Scanner{}, in) | |||
| 140 | + | if len(tokens) != 1 || tokens[0].kind != "bytes" || !bytes.Equal(tokens[0].data, in) { | |||
| 141 | + | t.Fatalf("tokens = %#v, want passthrough %q", tokens, in) | |||
| 142 | + | } | |||
| 143 | + | }) | |||
| 144 | + | } | |||
| 145 | + | } | |||
M pkg/vt/screen.go +61 -6
| @@ -49,13 +49,19 @@ type Screen struct { | |||
| 49 | 49 | savedCursor cursorState | |
| 50 | 50 | alternate *screenState | |
| 51 | 51 | syncUpdateActive bool | |
| 52 | + | cursorVisible bool | |
| 53 | + | cursorStyle int | |
| 54 | + | cursorStyleSet bool | |
| 55 | + | mouseMode int | |
| 56 | + | mouseSGR bool | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | 59 | func NewScreen(width, height int) *Screen { | |
| 55 | 60 | s := &Screen{ | |
| 56 | - | Frame: renderer.NewFrame(width, height), | |
| 57 | - | Style: renderer.DefaultStyle(), | |
| 58 | - | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 61 | + | Frame: renderer.NewFrame(width, height), | |
| 62 | + | Style: renderer.DefaultStyle(), | |
| 63 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 64 | + | cursorVisible: true, | |
| 59 | 65 | } | |
| 60 | 66 | s.resetScrollRegion() | |
| 61 | 67 | return s | |
| @@ -71,6 +77,11 @@ func (s *Screen) Resize(width, height int) { | |||
| 71 | 77 | s.escapeBuf = s.escapeBuf[:0] | |
| 72 | 78 | s.savedCursor = cursorState{} | |
| 73 | 79 | s.alternate = nil | |
| 80 | + | s.cursorVisible = true | |
| 81 | + | s.cursorStyle = 0 | |
| 82 | + | s.cursorStyleSet = false | |
| 83 | + | s.mouseMode = 0 | |
| 84 | + | s.mouseSGR = false | |
| 74 | 85 | s.resetScrollRegion() | |
| 75 | 86 | s.fullRedraw() | |
| 76 | 87 | } | |
| @@ -89,6 +100,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||
| 89 | 100 | // and never sends the matching end sequence. | |
| 90 | 101 | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | |
| 91 | 102 | ||
| 103 | + | func (s *Screen) CursorRow() int { return s.Row } | |
| 104 | + | func (s *Screen) CursorCol() int { return s.Col } | |
| 105 | + | func (s *Screen) CursorVisible() bool { | |
| 106 | + | return s.cursorVisible | |
| 107 | + | } | |
| 108 | + | func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet } | |
| 109 | + | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |
| 110 | + | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | |
| 111 | + | ||
| 92 | 112 | func (s *Screen) Write(data []byte) { | |
| 93 | 113 | if len(s.escapeBuf) > 0 { | |
| 94 | 114 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |
| @@ -645,9 +665,10 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 645 | 665 | parts := parseCSIInts(params) | |
| 646 | 666 | switch cmd { | |
| 647 | 667 | case 'c': | |
| 648 | - | if params == "" || params == "0" { | |
| 668 | + | switch params { | |
| 669 | + | case "", "0": | |
| 649 | 670 | s.respond([]byte("\x1b[?6c")) | |
| 650 | - | } else if params == ">" || params == ">0" { | |
| 671 | + | case ">", ">0": | |
| 651 | 672 | s.respond([]byte("\x1b[>0;0;0c")) | |
| 652 | 673 | } | |
| 653 | 674 | case 'n': | |
| @@ -694,6 +715,8 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||
| 694 | 715 | return | |
| 695 | 716 | } | |
| 696 | 717 | s.applySGR(params) | |
| 718 | + | case 'q': | |
| 719 | + | s.applyCursorStyle(params) | |
| 697 | 720 | case 'J': | |
| 698 | 721 | mode := 0 | |
| 699 | 722 | if len(parts) > 0 { | |
| @@ -770,6 +793,23 @@ func firstPositive(parts []int, fallback int) int { | |||
| 770 | 793 | return parts[0] | |
| 771 | 794 | } | |
| 772 | 795 | ||
| 796 | + | func (s *Screen) applyCursorStyle(params string) { | |
| 797 | + | if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") { | |
| 798 | + | return | |
| 799 | + | } | |
| 800 | + | styleParam := strings.TrimSuffix(params, " ") | |
| 801 | + | style := 0 | |
| 802 | + | if styleParam != "" { | |
| 803 | + | v, err := strconv.Atoi(styleParam) | |
| 804 | + | if err != nil { | |
| 805 | + | return | |
| 806 | + | } | |
| 807 | + | style = v | |
| 808 | + | } | |
| 809 | + | s.cursorStyle = style | |
| 810 | + | s.cursorStyleSet = true | |
| 811 | + | } | |
| 812 | + | ||
| 773 | 813 | func (s *Screen) applySGR(params string) { | |
| 774 | 814 | if params == "" { | |
| 775 | 815 | s.Style = renderer.DefaultStyle() | |
| @@ -860,6 +900,11 @@ func (s *Screen) reset() { | |||
| 860 | 900 | s.escapeBuf = s.escapeBuf[:0] | |
| 861 | 901 | s.savedCursor = cursorState{} | |
| 862 | 902 | s.alternate = nil | |
| 903 | + | s.cursorVisible = true | |
| 904 | + | s.cursorStyle = 0 | |
| 905 | + | s.cursorStyleSet = false | |
| 906 | + | s.mouseMode = 0 | |
| 907 | + | s.mouseSGR = false | |
| 863 | 908 | s.resetScrollRegion() | |
| 864 | 909 | s.fullRedraw() | |
| 865 | 910 | } | |
| @@ -910,7 +955,17 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||
| 910 | 955 | } | |
| 911 | 956 | case 2026: | |
| 912 | 957 | s.syncUpdateActive = enabled | |
| 913 | - | case 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 2004: | |
| 958 | + | case 25: | |
| 959 | + | s.cursorVisible = enabled | |
| 960 | + | case 1000, 1002, 1003: | |
| 961 | + | if enabled { | |
| 962 | + | s.mouseMode = mode | |
| 963 | + | } else if s.mouseMode == mode { | |
| 964 | + | s.mouseMode = 0 | |
| 965 | + | } | |
| 966 | + | case 1006: | |
| 967 | + | s.mouseSGR = enabled | |
| 968 | + | case 1, 1004, 1005, 2004: | |
| 914 | 969 | // Trackable terminal modes that do not directly affect the current | |
| 915 | 970 | // cell model yet. Consuming them prevents mode bytes from leaking. | |
| 916 | 971 | continue | |
| @@ -49,13 +49,19 @@ type Screen struct { | |||||
| 49 | savedCursor cursorState | 49 | savedCursor cursorState | ||
| 50 | alternate *screenState | 50 | alternate *screenState | ||
| 51 | syncUpdateActive bool | 51 | syncUpdateActive bool | ||
| 52 | + | cursorVisible bool | |||
| 53 | + | cursorStyle int | |||
| 54 | + | cursorStyleSet bool | |||
| 55 | + | mouseMode int | |||
| 56 | + | mouseSGR bool | |||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| 54 | func NewScreen(width, height int) *Screen { | 59 | func NewScreen(width, height int) *Screen { | ||
| 55 | s := &Screen{ | 60 | s := &Screen{ | ||
| 56 | - | Frame: renderer.NewFrame(width, height), | 61 | + | Frame: renderer.NewFrame(width, height), |
| 57 | - | Style: renderer.DefaultStyle(), | 62 | + | Style: renderer.DefaultStyle(), |
| 58 | - | damage: []renderer.Damage{renderer.FullRedraw()}, | 63 | + | damage: []renderer.Damage{renderer.FullRedraw()}, |
| 64 | + | cursorVisible: true, | |||
| 59 | } | 65 | } | ||
| 60 | s.resetScrollRegion() | 66 | s.resetScrollRegion() | ||
| 61 | return s | 67 | return s | ||
| @@ -71,6 +77,11 @@ func (s *Screen) Resize(width, height int) { | |||||
| 71 | s.escapeBuf = s.escapeBuf[:0] | 77 | s.escapeBuf = s.escapeBuf[:0] | ||
| 72 | s.savedCursor = cursorState{} | 78 | s.savedCursor = cursorState{} | ||
| 73 | s.alternate = nil | 79 | s.alternate = nil | ||
| 80 | + | s.cursorVisible = true | |||
| 81 | + | s.cursorStyle = 0 | |||
| 82 | + | s.cursorStyleSet = false | |||
| 83 | + | s.mouseMode = 0 | |||
| 84 | + | s.mouseSGR = false | |||
| 74 | s.resetScrollRegion() | 85 | s.resetScrollRegion() | ||
| 75 | s.fullRedraw() | 86 | s.fullRedraw() | ||
| 76 | } | 87 | } | ||
| @@ -89,6 +100,15 @@ func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |||||
| 89 | // and never sends the matching end sequence. | 100 | // and never sends the matching end sequence. | ||
| 90 | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | 101 | func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false } | ||
| 91 | 102 | ||||
| 103 | + | func (s *Screen) CursorRow() int { return s.Row } | |||
| 104 | + | func (s *Screen) CursorCol() int { return s.Col } | |||
| 105 | + | func (s *Screen) CursorVisible() bool { | |||
| 106 | + | return s.cursorVisible | |||
| 107 | + | } | |||
| 108 | + | func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet } | |||
| 109 | + | func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR } | |||
| 110 | + | func (s *Screen) AltScreenActive() bool { return s.alternate != nil } | |||
| 111 | + | ||||
| 92 | func (s *Screen) Write(data []byte) { | 112 | func (s *Screen) Write(data []byte) { | ||
| 93 | if len(s.escapeBuf) > 0 { | 113 | if len(s.escapeBuf) > 0 { | ||
| 94 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | 114 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | ||
| @@ -645,9 +665,10 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 645 | parts := parseCSIInts(params) | 665 | parts := parseCSIInts(params) | ||
| 646 | switch cmd { | 666 | switch cmd { | ||
| 647 | case 'c': | 667 | case 'c': | ||
| 648 | - | if params == "" || params == "0" { | 668 | + | switch params { |
| 669 | + | case "", "0": | |||
| 649 | s.respond([]byte("\x1b[?6c")) | 670 | s.respond([]byte("\x1b[?6c")) | ||
| 650 | - | } else if params == ">" || params == ">0" { | 671 | + | case ">", ">0": |
| 651 | s.respond([]byte("\x1b[>0;0;0c")) | 672 | s.respond([]byte("\x1b[>0;0;0c")) | ||
| 652 | } | 673 | } | ||
| 653 | case 'n': | 674 | case 'n': | ||
| @@ -694,6 +715,8 @@ func (s *Screen) applyCSI(params string, cmd byte) { | |||||
| 694 | return | 715 | return | ||
| 695 | } | 716 | } | ||
| 696 | s.applySGR(params) | 717 | s.applySGR(params) | ||
| 718 | + | case 'q': | |||
| 719 | + | s.applyCursorStyle(params) | |||
| 697 | case 'J': | 720 | case 'J': | ||
| 698 | mode := 0 | 721 | mode := 0 | ||
| 699 | if len(parts) > 0 { | 722 | if len(parts) > 0 { | ||
| @@ -770,6 +793,23 @@ func firstPositive(parts []int, fallback int) int { | |||||
| 770 | return parts[0] | 793 | return parts[0] | ||
| 771 | } | 794 | } | ||
| 772 | 795 | ||||
| 796 | + | func (s *Screen) applyCursorStyle(params string) { | |||
| 797 | + | if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") { | |||
| 798 | + | return | |||
| 799 | + | } | |||
| 800 | + | styleParam := strings.TrimSuffix(params, " ") | |||
| 801 | + | style := 0 | |||
| 802 | + | if styleParam != "" { | |||
| 803 | + | v, err := strconv.Atoi(styleParam) | |||
| 804 | + | if err != nil { | |||
| 805 | + | return | |||
| 806 | + | } | |||
| 807 | + | style = v | |||
| 808 | + | } | |||
| 809 | + | s.cursorStyle = style | |||
| 810 | + | s.cursorStyleSet = true | |||
| 811 | + | } | |||
| 812 | + | ||||
| 773 | func (s *Screen) applySGR(params string) { | 813 | func (s *Screen) applySGR(params string) { | ||
| 774 | if params == "" { | 814 | if params == "" { | ||
| 775 | s.Style = renderer.DefaultStyle() | 815 | s.Style = renderer.DefaultStyle() | ||
| @@ -860,6 +900,11 @@ func (s *Screen) reset() { | |||||
| 860 | s.escapeBuf = s.escapeBuf[:0] | 900 | s.escapeBuf = s.escapeBuf[:0] | ||
| 861 | s.savedCursor = cursorState{} | 901 | s.savedCursor = cursorState{} | ||
| 862 | s.alternate = nil | 902 | s.alternate = nil | ||
| 903 | + | s.cursorVisible = true | |||
| 904 | + | s.cursorStyle = 0 | |||
| 905 | + | s.cursorStyleSet = false | |||
| 906 | + | s.mouseMode = 0 | |||
| 907 | + | s.mouseSGR = false | |||
| 863 | s.resetScrollRegion() | 908 | s.resetScrollRegion() | ||
| 864 | s.fullRedraw() | 909 | s.fullRedraw() | ||
| 865 | } | 910 | } | ||
| @@ -910,7 +955,17 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) { | |||||
| 910 | } | 955 | } | ||
| 911 | case 2026: | 956 | case 2026: | ||
| 912 | s.syncUpdateActive = enabled | 957 | s.syncUpdateActive = enabled | ||
| 913 | - | case 1, 25, 1000, 1002, 1003, 1004, 1005, 1006, 2004: | 958 | + | case 25: |
| 959 | + | s.cursorVisible = enabled | |||
| 960 | + | case 1000, 1002, 1003: | |||
| 961 | + | if enabled { | |||
| 962 | + | s.mouseMode = mode | |||
| 963 | + | } else if s.mouseMode == mode { | |||
| 964 | + | s.mouseMode = 0 | |||
| 965 | + | } | |||
| 966 | + | case 1006: | |||
| 967 | + | s.mouseSGR = enabled | |||
| 968 | + | case 1, 1004, 1005, 2004: | |||
| 914 | // Trackable terminal modes that do not directly affect the current | 969 | // Trackable terminal modes that do not directly affect the current | ||
| 915 | // cell model yet. Consuming them prevents mode bytes from leaking. | 970 | // cell model yet. Consuming them prevents mode bytes from leaking. | ||
| 916 | continue | 971 | continue | ||
M pkg/vt/screen_test.go +105 -0
| @@ -2204,3 +2204,108 @@ func TestForceSyncEnd(t *testing.T) { | |||
| 2204 | 2204 | t.Fatal("sync update mode should be inactive after forcing end") | |
| 2205 | 2205 | } | |
| 2206 | 2206 | } | |
| 2207 | + | ||
| 2208 | + | func TestScreenCursorAndMouseStateAccessors(t *testing.T) { | |
| 2209 | + | tests := []struct { | |
| 2210 | + | name string | |
| 2211 | + | seq string | |
| 2212 | + | check func(t *testing.T, s *Screen) | |
| 2213 | + | }{ | |
| 2214 | + | { | |
| 2215 | + | name: "cursor position and visibility are exposed", | |
| 2216 | + | seq: "\x1b[2;3H\x1b[?25l", | |
| 2217 | + | check: func(t *testing.T, s *Screen) { | |
| 2218 | + | if s.CursorRow() != 1 || s.CursorCol() != 2 { | |
| 2219 | + | t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol()) | |
| 2220 | + | } | |
| 2221 | + | if s.CursorVisible() { | |
| 2222 | + | t.Fatal("cursor should be hidden") | |
| 2223 | + | } | |
| 2224 | + | s.Write([]byte("\x1b[?25h")) | |
| 2225 | + | if !s.CursorVisible() { | |
| 2226 | + | t.Fatal("cursor should be visible") | |
| 2227 | + | } | |
| 2228 | + | }, | |
| 2229 | + | }, | |
| 2230 | + | { | |
| 2231 | + | name: "mouse mode and SGR are exposed", | |
| 2232 | + | seq: "\x1b[?1002h\x1b[?1006h", | |
| 2233 | + | check: func(t *testing.T, s *Screen) { | |
| 2234 | + | mode, sgr := s.MouseMode() | |
| 2235 | + | if mode != 1002 || !sgr { | |
| 2236 | + | t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr) | |
| 2237 | + | } | |
| 2238 | + | }, | |
| 2239 | + | }, | |
| 2240 | + | { | |
| 2241 | + | name: "reset restores cursor and mouse defaults", | |
| 2242 | + | seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc", | |
| 2243 | + | check: func(t *testing.T, s *Screen) { | |
| 2244 | + | mode, sgr := s.MouseMode() | |
| 2245 | + | if !s.CursorVisible() || mode != 0 || sgr { | |
| 2246 | + | t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr) | |
| 2247 | + | } | |
| 2248 | + | }, | |
| 2249 | + | }, | |
| 2250 | + | } | |
| 2251 | + | for _, tt := range tests { | |
| 2252 | + | t.Run(tt.name, func(t *testing.T) { | |
| 2253 | + | s := NewScreen(10, 4) | |
| 2254 | + | s.Write([]byte(tt.seq)) | |
| 2255 | + | tt.check(t, s) | |
| 2256 | + | }) | |
| 2257 | + | } | |
| 2258 | + | } | |
| 2259 | + | ||
| 2260 | + | func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) { | |
| 2261 | + | s := NewScreen(10, 2) | |
| 2262 | + | s.Write([]byte("\x1b[?1002h")) | |
| 2263 | + | s.Write([]byte("\x1b[?1000l")) | |
| 2264 | + | mode, _ := s.MouseMode() | |
| 2265 | + | if mode != 1002 { | |
| 2266 | + | t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode) | |
| 2267 | + | } | |
| 2268 | + | s.Write([]byte("\x1b[?1002l")) | |
| 2269 | + | mode, _ = s.MouseMode() | |
| 2270 | + | if mode != 0 { | |
| 2271 | + | t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode) | |
| 2272 | + | } | |
| 2273 | + | } | |
| 2274 | + | ||
| 2275 | + | func TestScreenCursorStyleDECSCUSR(t *testing.T) { | |
| 2276 | + | tests := []struct { | |
| 2277 | + | name string | |
| 2278 | + | seq string | |
| 2279 | + | wantStyle int | |
| 2280 | + | wantSet bool | |
| 2281 | + | }{ | |
| 2282 | + | {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true}, | |
| 2283 | + | {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true}, | |
| 2284 | + | {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false}, | |
| 2285 | + | } | |
| 2286 | + | for _, tt := range tests { | |
| 2287 | + | t.Run(tt.name, func(t *testing.T) { | |
| 2288 | + | s := NewScreen(10, 2) | |
| 2289 | + | s.Write([]byte(tt.seq)) | |
| 2290 | + | style, set := s.CursorStyle() | |
| 2291 | + | if style != tt.wantStyle || set != tt.wantSet { | |
| 2292 | + | t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet) | |
| 2293 | + | } | |
| 2294 | + | }) | |
| 2295 | + | } | |
| 2296 | + | } | |
| 2297 | + | ||
| 2298 | + | func TestScreenAltScreenActiveAccessor(t *testing.T) { | |
| 2299 | + | s := NewScreen(10, 2) | |
| 2300 | + | if s.AltScreenActive() { | |
| 2301 | + | t.Fatal("alt screen should start inactive") | |
| 2302 | + | } | |
| 2303 | + | s.Write([]byte("\x1b[?1049h")) | |
| 2304 | + | if !s.AltScreenActive() { | |
| 2305 | + | t.Fatal("alt screen should be active") | |
| 2306 | + | } | |
| 2307 | + | s.Write([]byte("\x1b[?1049l")) | |
| 2308 | + | if s.AltScreenActive() { | |
| 2309 | + | t.Fatal("alt screen should be inactive after exit") | |
| 2310 | + | } | |
| 2311 | + | } | |
| @@ -2204,3 +2204,108 @@ func TestForceSyncEnd(t *testing.T) { | |||||
| 2204 | t.Fatal("sync update mode should be inactive after forcing end") | 2204 | t.Fatal("sync update mode should be inactive after forcing end") | ||
| 2205 | } | 2205 | } | ||
| 2206 | } | 2206 | } | ||
| 2207 | + | ||||
| 2208 | + | func TestScreenCursorAndMouseStateAccessors(t *testing.T) { | |||
| 2209 | + | tests := []struct { | |||
| 2210 | + | name string | |||
| 2211 | + | seq string | |||
| 2212 | + | check func(t *testing.T, s *Screen) | |||
| 2213 | + | }{ | |||
| 2214 | + | { | |||
| 2215 | + | name: "cursor position and visibility are exposed", | |||
| 2216 | + | seq: "\x1b[2;3H\x1b[?25l", | |||
| 2217 | + | check: func(t *testing.T, s *Screen) { | |||
| 2218 | + | if s.CursorRow() != 1 || s.CursorCol() != 2 { | |||
| 2219 | + | t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol()) | |||
| 2220 | + | } | |||
| 2221 | + | if s.CursorVisible() { | |||
| 2222 | + | t.Fatal("cursor should be hidden") | |||
| 2223 | + | } | |||
| 2224 | + | s.Write([]byte("\x1b[?25h")) | |||
| 2225 | + | if !s.CursorVisible() { | |||
| 2226 | + | t.Fatal("cursor should be visible") | |||
| 2227 | + | } | |||
| 2228 | + | }, | |||
| 2229 | + | }, | |||
| 2230 | + | { | |||
| 2231 | + | name: "mouse mode and SGR are exposed", | |||
| 2232 | + | seq: "\x1b[?1002h\x1b[?1006h", | |||
| 2233 | + | check: func(t *testing.T, s *Screen) { | |||
| 2234 | + | mode, sgr := s.MouseMode() | |||
| 2235 | + | if mode != 1002 || !sgr { | |||
| 2236 | + | t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr) | |||
| 2237 | + | } | |||
| 2238 | + | }, | |||
| 2239 | + | }, | |||
| 2240 | + | { | |||
| 2241 | + | name: "reset restores cursor and mouse defaults", | |||
| 2242 | + | seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc", | |||
| 2243 | + | check: func(t *testing.T, s *Screen) { | |||
| 2244 | + | mode, sgr := s.MouseMode() | |||
| 2245 | + | if !s.CursorVisible() || mode != 0 || sgr { | |||
| 2246 | + | t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr) | |||
| 2247 | + | } | |||
| 2248 | + | }, | |||
| 2249 | + | }, | |||
| 2250 | + | } | |||
| 2251 | + | for _, tt := range tests { | |||
| 2252 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 2253 | + | s := NewScreen(10, 4) | |||
| 2254 | + | s.Write([]byte(tt.seq)) | |||
| 2255 | + | tt.check(t, s) | |||
| 2256 | + | }) | |||
| 2257 | + | } | |||
| 2258 | + | } | |||
| 2259 | + | ||||
| 2260 | + | func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) { | |||
| 2261 | + | s := NewScreen(10, 2) | |||
| 2262 | + | s.Write([]byte("\x1b[?1002h")) | |||
| 2263 | + | s.Write([]byte("\x1b[?1000l")) | |||
| 2264 | + | mode, _ := s.MouseMode() | |||
| 2265 | + | if mode != 1002 { | |||
| 2266 | + | t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode) | |||
| 2267 | + | } | |||
| 2268 | + | s.Write([]byte("\x1b[?1002l")) | |||
| 2269 | + | mode, _ = s.MouseMode() | |||
| 2270 | + | if mode != 0 { | |||
| 2271 | + | t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode) | |||
| 2272 | + | } | |||
| 2273 | + | } | |||
| 2274 | + | ||||
| 2275 | + | func TestScreenCursorStyleDECSCUSR(t *testing.T) { | |||
| 2276 | + | tests := []struct { | |||
| 2277 | + | name string | |||
| 2278 | + | seq string | |||
| 2279 | + | wantStyle int | |||
| 2280 | + | wantSet bool | |||
| 2281 | + | }{ | |||
| 2282 | + | {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true}, | |||
| 2283 | + | {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true}, | |||
| 2284 | + | {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false}, | |||
| 2285 | + | } | |||
| 2286 | + | for _, tt := range tests { | |||
| 2287 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 2288 | + | s := NewScreen(10, 2) | |||
| 2289 | + | s.Write([]byte(tt.seq)) | |||
| 2290 | + | style, set := s.CursorStyle() | |||
| 2291 | + | if style != tt.wantStyle || set != tt.wantSet { | |||
| 2292 | + | t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet) | |||
| 2293 | + | } | |||
| 2294 | + | }) | |||
| 2295 | + | } | |||
| 2296 | + | } | |||
| 2297 | + | ||||
| 2298 | + | func TestScreenAltScreenActiveAccessor(t *testing.T) { | |||
| 2299 | + | s := NewScreen(10, 2) | |||
| 2300 | + | if s.AltScreenActive() { | |||
| 2301 | + | t.Fatal("alt screen should start inactive") | |||
| 2302 | + | } | |||
| 2303 | + | s.Write([]byte("\x1b[?1049h")) | |||
| 2304 | + | if !s.AltScreenActive() { | |||
| 2305 | + | t.Fatal("alt screen should be active") | |||
| 2306 | + | } | |||
| 2307 | + | s.Write([]byte("\x1b[?1049l")) | |||
| 2308 | + | if s.AltScreenActive() { | |||
| 2309 | + | t.Fatal("alt screen should be inactive after exit") | |||
| 2310 | + | } | |||
| 2311 | + | } | |||