feat: add copy mode osc52
- commit
- d9a1d1853f905a424a5348280b5272c5f884f7a7
- author
- brice · · 2026-07-02 15:56 UTC
- parent
- 9548cb5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +556 -7
A internal/usecase/copy/mode.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/base64" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | const OSC52ChunkSize = 75_000 | |
| 11 | + | ||
| 12 | + | // Snapshot is the copy-mode document: scrollback followed by the live screen. | |
| 13 | + | type Snapshot struct { | |
| 14 | + | Rows [][]renderer.Cell | |
| 15 | + | Width int | |
| 16 | + | Height int | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewSnapshot copies rows from scrollback and then the live screen frame. | |
| 20 | + | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |
| 21 | + | scrollRows := 0 | |
| 22 | + | if sb != nil { | |
| 23 | + | scrollRows = sb.Len() | |
| 24 | + | } | |
| 25 | + | rows := make([][]renderer.Cell, 0, scrollRows+screen.Height) | |
| 26 | + | if sb != nil { | |
| 27 | + | for i := range sb.Len() { | |
| 28 | + | rows = append(rows, sb.Row(i)) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | for y := range screen.Height { | |
| 32 | + | rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...)) | |
| 33 | + | } | |
| 34 | + | return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Mode stores per-client copy-mode viewport and line-selection state. | |
| 38 | + | type Mode struct { | |
| 39 | + | ViewportTop int | |
| 40 | + | Cursor int | |
| 41 | + | Anchor int | |
| 42 | + | Selecting bool | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func NewMode(s Snapshot) *Mode { | |
| 46 | + | m := &Mode{Anchor: -1} | |
| 47 | + | m.ViewportTop = max(len(s.Rows)-s.Height, 0) | |
| 48 | + | m.Cursor = max(len(s.Rows)-1, 0) | |
| 49 | + | m.clamp(s) | |
| 50 | + | return m | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (m *Mode) Move(s Snapshot, delta int) { m.setCursor(s, m.Cursor+delta) } | |
| 54 | + | func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |
| 55 | + | func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) } | |
| 56 | + | func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) } | |
| 57 | + | ||
| 58 | + | func (m *Mode) ToggleSelection() { | |
| 59 | + | if m.Selecting { | |
| 60 | + | m.Selecting = false | |
| 61 | + | m.Anchor = -1 | |
| 62 | + | return | |
| 63 | + | } | |
| 64 | + | m.Selecting = true | |
| 65 | + | m.Anchor = m.Cursor | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (m *Mode) setCursor(s Snapshot, cursor int) { | |
| 69 | + | m.Cursor = cursor | |
| 70 | + | m.clamp(s) | |
| 71 | + | if m.Cursor < m.ViewportTop { | |
| 72 | + | m.ViewportTop = m.Cursor | |
| 73 | + | } | |
| 74 | + | if bottom := m.ViewportTop + max(s.Height, 1) - 1; m.Cursor > bottom { | |
| 75 | + | m.ViewportTop = m.Cursor - max(s.Height, 1) + 1 | |
| 76 | + | } | |
| 77 | + | m.clamp(s) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (m *Mode) clamp(s Snapshot) { | |
| 81 | + | total := len(s.Rows) | |
| 82 | + | if total == 0 { | |
| 83 | + | m.Cursor, m.ViewportTop = 0, 0 | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | m.Cursor = min(max(m.Cursor, 0), total-1) | |
| 87 | + | maxTop := max(total-max(s.Height, 1), 0) | |
| 88 | + | m.ViewportTop = min(max(m.ViewportTop, 0), maxTop) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (m *Mode) SelectedBounds() (int, int, bool) { | |
| 92 | + | if !m.Selecting || m.Anchor < 0 { | |
| 93 | + | return 0, 0, false | |
| 94 | + | } | |
| 95 | + | lo, hi := m.Anchor, m.Cursor | |
| 96 | + | if lo > hi { | |
| 97 | + | lo, hi = hi, lo | |
| 98 | + | } | |
| 99 | + | return lo, hi, true | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (m *Mode) SelectedText(s Snapshot) string { | |
| 103 | + | lo, hi, ok := m.SelectedBounds() | |
| 104 | + | if !ok || len(s.Rows) == 0 { | |
| 105 | + | return "" | |
| 106 | + | } | |
| 107 | + | lo = max(lo, 0) | |
| 108 | + | hi = min(hi, len(s.Rows)-1) | |
| 109 | + | lines := make([]string, 0, hi-lo+1) | |
| 110 | + | for i := lo; i <= hi; i++ { | |
| 111 | + | lines = append(lines, rowString(s.Rows[i])) | |
| 112 | + | } | |
| 113 | + | return strings.Join(lines, "\n") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (m *Mode) Render(s Snapshot) renderer.Frame { | |
| 117 | + | m.clamp(s) | |
| 118 | + | frame := renderer.NewFrame(s.Width, s.Height+1) | |
| 119 | + | lo, hi, selected := m.SelectedBounds() | |
| 120 | + | for y := range s.Height { | |
| 121 | + | src := m.ViewportTop + y | |
| 122 | + | if src >= len(s.Rows) { | |
| 123 | + | break | |
| 124 | + | } | |
| 125 | + | row := frame.Row(y) | |
| 126 | + | copy(row, s.Rows[src]) | |
| 127 | + | if selected && src >= lo && src <= hi { | |
| 128 | + | for x := range row { | |
| 129 | + | row[x].Style.Inverse = !row[x].Style.Inverse | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows)) | |
| 134 | + | return frame | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func rowString(row []renderer.Cell) string { | |
| 138 | + | runes := make([]rune, 0, len(row)) | |
| 139 | + | for _, c := range row { | |
| 140 | + | if c.Continuation { | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | r := c.Rune | |
| 144 | + | if r == 0 { | |
| 145 | + | r = ' ' | |
| 146 | + | } | |
| 147 | + | runes = append(runes, r) | |
| 148 | + | } | |
| 149 | + | return strings.TrimRight(string(runes), " ") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |
| 153 | + | for i := range row { | |
| 154 | + | row[i] = renderer.BlankCell() | |
| 155 | + | } | |
| 156 | + | text := " [COPY] " | |
| 157 | + | if total > 0 { | |
| 158 | + | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | |
| 159 | + | } else { | |
| 160 | + | text += "0/0 " | |
| 161 | + | } | |
| 162 | + | style := renderer.DefaultStyle() | |
| 163 | + | style.Inverse = true | |
| 164 | + | for i, r := range text { | |
| 165 | + | if i >= len(row) { | |
| 166 | + | break | |
| 167 | + | } | |
| 168 | + | row[i] = renderer.Cell{Rune: r, Style: style} | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // OSC52 encodes text for clipboard transfer. Large payloads are split into | |
| 173 | + | // multiple complete OSC 52 sequences so each MsgOutput remains bounded. | |
| 174 | + | func OSC52(text string) [][]byte { | |
| 175 | + | encoded := base64.StdEncoding.EncodeToString([]byte(text)) | |
| 176 | + | if encoded == "" { | |
| 177 | + | return [][]byte{[]byte("\x1b]52;c;\x07")} | |
| 178 | + | } | |
| 179 | + | chunks := make([][]byte, 0, len(encoded)/OSC52ChunkSize+1) | |
| 180 | + | for len(encoded) > 0 { | |
| 181 | + | n := min(len(encoded), OSC52ChunkSize) | |
| 182 | + | chunks = append(chunks, []byte("\x1b]52;c;"+encoded[:n]+"\x07")) | |
| 183 | + | encoded = encoded[n:] | |
| 184 | + | } | |
| 185 | + | return chunks | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func strconvItoa(n int) string { | |
| 189 | + | if n == 0 { | |
| 190 | + | return "0" | |
| 191 | + | } | |
| 192 | + | var buf [20]byte | |
| 193 | + | i := len(buf) | |
| 194 | + | for n > 0 { | |
| 195 | + | i-- | |
| 196 | + | buf[i] = byte('0' + n%10) | |
| 197 | + | n /= 10 | |
| 198 | + | } | |
| 199 | + | return string(buf[i:]) | |
| 200 | + | } | |
| @@ -0,0 +1,200 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/base64" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | const OSC52ChunkSize = 75_000 | |||
| 11 | + | ||||
| 12 | + | // Snapshot is the copy-mode document: scrollback followed by the live screen. | |||
| 13 | + | type Snapshot struct { | |||
| 14 | + | Rows [][]renderer.Cell | |||
| 15 | + | Width int | |||
| 16 | + | Height int | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // NewSnapshot copies rows from scrollback and then the live screen frame. | |||
| 20 | + | func NewSnapshot(sb *Scrollback, screen renderer.Frame) Snapshot { | |||
| 21 | + | scrollRows := 0 | |||
| 22 | + | if sb != nil { | |||
| 23 | + | scrollRows = sb.Len() | |||
| 24 | + | } | |||
| 25 | + | rows := make([][]renderer.Cell, 0, scrollRows+screen.Height) | |||
| 26 | + | if sb != nil { | |||
| 27 | + | for i := range sb.Len() { | |||
| 28 | + | rows = append(rows, sb.Row(i)) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | for y := range screen.Height { | |||
| 32 | + | rows = append(rows, append([]renderer.Cell(nil), screen.Row(y)...)) | |||
| 33 | + | } | |||
| 34 | + | return Snapshot{Rows: rows, Width: screen.Width, Height: screen.Height} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Mode stores per-client copy-mode viewport and line-selection state. | |||
| 38 | + | type Mode struct { | |||
| 39 | + | ViewportTop int | |||
| 40 | + | Cursor int | |||
| 41 | + | Anchor int | |||
| 42 | + | Selecting bool | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func NewMode(s Snapshot) *Mode { | |||
| 46 | + | m := &Mode{Anchor: -1} | |||
| 47 | + | m.ViewportTop = max(len(s.Rows)-s.Height, 0) | |||
| 48 | + | m.Cursor = max(len(s.Rows)-1, 0) | |||
| 49 | + | m.clamp(s) | |||
| 50 | + | return m | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func (m *Mode) Move(s Snapshot, delta int) { m.setCursor(s, m.Cursor+delta) } | |||
| 54 | + | func (m *Mode) Page(s Snapshot, pages int) { m.setCursor(s, m.Cursor+pages*max(s.Height, 1)) } | |||
| 55 | + | func (m *Mode) Top(s Snapshot) { m.setCursor(s, 0) } | |||
| 56 | + | func (m *Mode) Bottom(s Snapshot) { m.setCursor(s, len(s.Rows)-1) } | |||
| 57 | + | ||||
| 58 | + | func (m *Mode) ToggleSelection() { | |||
| 59 | + | if m.Selecting { | |||
| 60 | + | m.Selecting = false | |||
| 61 | + | m.Anchor = -1 | |||
| 62 | + | return | |||
| 63 | + | } | |||
| 64 | + | m.Selecting = true | |||
| 65 | + | m.Anchor = m.Cursor | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (m *Mode) setCursor(s Snapshot, cursor int) { | |||
| 69 | + | m.Cursor = cursor | |||
| 70 | + | m.clamp(s) | |||
| 71 | + | if m.Cursor < m.ViewportTop { | |||
| 72 | + | m.ViewportTop = m.Cursor | |||
| 73 | + | } | |||
| 74 | + | if bottom := m.ViewportTop + max(s.Height, 1) - 1; m.Cursor > bottom { | |||
| 75 | + | m.ViewportTop = m.Cursor - max(s.Height, 1) + 1 | |||
| 76 | + | } | |||
| 77 | + | m.clamp(s) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (m *Mode) clamp(s Snapshot) { | |||
| 81 | + | total := len(s.Rows) | |||
| 82 | + | if total == 0 { | |||
| 83 | + | m.Cursor, m.ViewportTop = 0, 0 | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | m.Cursor = min(max(m.Cursor, 0), total-1) | |||
| 87 | + | maxTop := max(total-max(s.Height, 1), 0) | |||
| 88 | + | m.ViewportTop = min(max(m.ViewportTop, 0), maxTop) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (m *Mode) SelectedBounds() (int, int, bool) { | |||
| 92 | + | if !m.Selecting || m.Anchor < 0 { | |||
| 93 | + | return 0, 0, false | |||
| 94 | + | } | |||
| 95 | + | lo, hi := m.Anchor, m.Cursor | |||
| 96 | + | if lo > hi { | |||
| 97 | + | lo, hi = hi, lo | |||
| 98 | + | } | |||
| 99 | + | return lo, hi, true | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (m *Mode) SelectedText(s Snapshot) string { | |||
| 103 | + | lo, hi, ok := m.SelectedBounds() | |||
| 104 | + | if !ok || len(s.Rows) == 0 { | |||
| 105 | + | return "" | |||
| 106 | + | } | |||
| 107 | + | lo = max(lo, 0) | |||
| 108 | + | hi = min(hi, len(s.Rows)-1) | |||
| 109 | + | lines := make([]string, 0, hi-lo+1) | |||
| 110 | + | for i := lo; i <= hi; i++ { | |||
| 111 | + | lines = append(lines, rowString(s.Rows[i])) | |||
| 112 | + | } | |||
| 113 | + | return strings.Join(lines, "\n") | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func (m *Mode) Render(s Snapshot) renderer.Frame { | |||
| 117 | + | m.clamp(s) | |||
| 118 | + | frame := renderer.NewFrame(s.Width, s.Height+1) | |||
| 119 | + | lo, hi, selected := m.SelectedBounds() | |||
| 120 | + | for y := range s.Height { | |||
| 121 | + | src := m.ViewportTop + y | |||
| 122 | + | if src >= len(s.Rows) { | |||
| 123 | + | break | |||
| 124 | + | } | |||
| 125 | + | row := frame.Row(y) | |||
| 126 | + | copy(row, s.Rows[src]) | |||
| 127 | + | if selected && src >= lo && src <= hi { | |||
| 128 | + | for x := range row { | |||
| 129 | + | row[x].Style.Inverse = !row[x].Style.Inverse | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | drawCopyStatus(frame.Row(s.Height), m, len(s.Rows)) | |||
| 134 | + | return frame | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func rowString(row []renderer.Cell) string { | |||
| 138 | + | runes := make([]rune, 0, len(row)) | |||
| 139 | + | for _, c := range row { | |||
| 140 | + | if c.Continuation { | |||
| 141 | + | continue | |||
| 142 | + | } | |||
| 143 | + | r := c.Rune | |||
| 144 | + | if r == 0 { | |||
| 145 | + | r = ' ' | |||
| 146 | + | } | |||
| 147 | + | runes = append(runes, r) | |||
| 148 | + | } | |||
| 149 | + | return strings.TrimRight(string(runes), " ") | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | func drawCopyStatus(row []renderer.Cell, m *Mode, total int) { | |||
| 153 | + | for i := range row { | |||
| 154 | + | row[i] = renderer.BlankCell() | |||
| 155 | + | } | |||
| 156 | + | text := " [COPY] " | |||
| 157 | + | if total > 0 { | |||
| 158 | + | text += strconvItoa(m.Cursor+1) + "/" + strconvItoa(total) + " " | |||
| 159 | + | } else { | |||
| 160 | + | text += "0/0 " | |||
| 161 | + | } | |||
| 162 | + | style := renderer.DefaultStyle() | |||
| 163 | + | style.Inverse = true | |||
| 164 | + | for i, r := range text { | |||
| 165 | + | if i >= len(row) { | |||
| 166 | + | break | |||
| 167 | + | } | |||
| 168 | + | row[i] = renderer.Cell{Rune: r, Style: style} | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // OSC52 encodes text for clipboard transfer. Large payloads are split into | |||
| 173 | + | // multiple complete OSC 52 sequences so each MsgOutput remains bounded. | |||
| 174 | + | func OSC52(text string) [][]byte { | |||
| 175 | + | encoded := base64.StdEncoding.EncodeToString([]byte(text)) | |||
| 176 | + | if encoded == "" { | |||
| 177 | + | return [][]byte{[]byte("\x1b]52;c;\x07")} | |||
| 178 | + | } | |||
| 179 | + | chunks := make([][]byte, 0, len(encoded)/OSC52ChunkSize+1) | |||
| 180 | + | for len(encoded) > 0 { | |||
| 181 | + | n := min(len(encoded), OSC52ChunkSize) | |||
| 182 | + | chunks = append(chunks, []byte("\x1b]52;c;"+encoded[:n]+"\x07")) | |||
| 183 | + | encoded = encoded[n:] | |||
| 184 | + | } | |||
| 185 | + | return chunks | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func strconvItoa(n int) string { | |||
| 189 | + | if n == 0 { | |||
| 190 | + | return "0" | |||
| 191 | + | } | |||
| 192 | + | var buf [20]byte | |||
| 193 | + | i := len(buf) | |||
| 194 | + | for n > 0 { | |||
| 195 | + | i-- | |||
| 196 | + | buf[i] = byte('0' + n%10) | |||
| 197 | + | n /= 10 | |||
| 198 | + | } | |||
| 199 | + | return string(buf[i:]) | |||
| 200 | + | } | |||
A internal/usecase/copy/mode_test.go +138 -0
| @@ -0,0 +1,138 @@ | |||
| 1 | + | package copy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/base64" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func snapshot(lines []string, height int) Snapshot { | |
| 12 | + | rows := make([][]renderer.Cell, len(lines)) | |
| 13 | + | for i, line := range lines { | |
| 14 | + | rows[i] = row(line) | |
| 15 | + | } | |
| 16 | + | return Snapshot{Rows: rows, Width: 8, Height: height} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestCopyModeNavigationBounds(t *testing.T) { | |
| 20 | + | tests := []struct { | |
| 21 | + | name string | |
| 22 | + | lines []string | |
| 23 | + | height int | |
| 24 | + | ops func(*Mode, Snapshot) | |
| 25 | + | wantCursor int | |
| 26 | + | wantTop int | |
| 27 | + | }{ | |
| 28 | + | { | |
| 29 | + | name: "initial viewport starts at bottom", | |
| 30 | + | lines: []string{"00", "01", "02", "03", "04"}, | |
| 31 | + | height: 3, | |
| 32 | + | ops: func(*Mode, Snapshot) {}, | |
| 33 | + | wantCursor: 4, | |
| 34 | + | wantTop: 2, | |
| 35 | + | }, | |
| 36 | + | { | |
| 37 | + | name: "k clamps at top and scrolls viewport", | |
| 38 | + | lines: []string{"00", "01", "02", "03", "04"}, | |
| 39 | + | height: 3, | |
| 40 | + | ops: func(m *Mode, s Snapshot) { | |
| 41 | + | m.Move(s, -99) | |
| 42 | + | }, | |
| 43 | + | wantCursor: 0, | |
| 44 | + | wantTop: 0, | |
| 45 | + | }, | |
| 46 | + | { | |
| 47 | + | name: "j clamps at bottom", | |
| 48 | + | lines: []string{"00", "01", "02", "03", "04"}, | |
| 49 | + | height: 3, | |
| 50 | + | ops: func(m *Mode, s Snapshot) { | |
| 51 | + | m.Move(s, 99) | |
| 52 | + | }, | |
| 53 | + | wantCursor: 4, | |
| 54 | + | wantTop: 2, | |
| 55 | + | }, | |
| 56 | + | { | |
| 57 | + | name: "page up moves by viewport height", | |
| 58 | + | lines: []string{"00", "01", "02", "03", "04", "05", "06"}, | |
| 59 | + | height: 3, | |
| 60 | + | ops: func(m *Mode, s Snapshot) { | |
| 61 | + | m.Page(s, -1) | |
| 62 | + | }, | |
| 63 | + | wantCursor: 3, | |
| 64 | + | wantTop: 3, | |
| 65 | + | }, | |
| 66 | + | { | |
| 67 | + | name: "g and G jump to document bounds", | |
| 68 | + | lines: []string{"00", "01", "02", "03", "04"}, | |
| 69 | + | height: 3, | |
| 70 | + | ops: func(m *Mode, s Snapshot) { | |
| 71 | + | m.Top(s) | |
| 72 | + | m.Bottom(s) | |
| 73 | + | }, | |
| 74 | + | wantCursor: 4, | |
| 75 | + | wantTop: 2, | |
| 76 | + | }, | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | for _, tt := range tests { | |
| 80 | + | t.Run(tt.name, func(t *testing.T) { | |
| 81 | + | s := snapshot(tt.lines, tt.height) | |
| 82 | + | m := NewMode(s) | |
| 83 | + | tt.ops(m, s) | |
| 84 | + | if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop { | |
| 85 | + | t.Fatalf("cursor/top = %d/%d, want %d/%d", m.Cursor, m.ViewportTop, tt.wantCursor, tt.wantTop) | |
| 86 | + | } | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |
| 92 | + | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |
| 93 | + | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |
| 94 | + | m.Move(s, 1) | |
| 95 | + | ||
| 96 | + | if got, want := m.SelectedText(s), "alpha\nbeta"; got != want { | |
| 97 | + | t.Fatalf("SelectedText() = %q, want %q", got, want) | |
| 98 | + | } | |
| 99 | + | frame := m.Render(s) | |
| 100 | + | for y := 0; y < 2; y++ { | |
| 101 | + | if !frame.At(0, y).Style.Inverse { | |
| 102 | + | t.Fatalf("row %d first cell not inverse", y) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestOSC52Base64EncodingAndChunking(t *testing.T) { | |
| 108 | + | tests := []struct { | |
| 109 | + | name string | |
| 110 | + | text string | |
| 111 | + | }{ | |
| 112 | + | {name: "small payload", text: "hello\nworld"}, | |
| 113 | + | {name: "chunked payload", text: strings.Repeat("x", OSC52ChunkSize)}, | |
| 114 | + | } | |
| 115 | + | for _, tt := range tests { | |
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |
| 117 | + | chunks := OSC52(tt.text) | |
| 118 | + | joined := "" | |
| 119 | + | for _, chunk := range chunks { | |
| 120 | + | s := string(chunk) | |
| 121 | + | if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") { | |
| 122 | + | t.Fatalf("chunk %q missing OSC52 wrapper", s) | |
| 123 | + | } | |
| 124 | + | joined += strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07") | |
| 125 | + | } | |
| 126 | + | decoded, err := base64.StdEncoding.DecodeString(joined) | |
| 127 | + | if err != nil { | |
| 128 | + | t.Fatalf("DecodeString() error = %v", err) | |
| 129 | + | } | |
| 130 | + | if string(decoded) != tt.text { | |
| 131 | + | t.Fatalf("decoded = %q, want %q", string(decoded), tt.text) | |
| 132 | + | } | |
| 133 | + | if tt.name == "chunked payload" && len(chunks) < 2 { | |
| 134 | + | t.Fatalf("len(chunks) = %d, want chunking", len(chunks)) | |
| 135 | + | } | |
| 136 | + | }) | |
| 137 | + | } | |
| 138 | + | } | |
| @@ -0,0 +1,138 @@ | |||||
| 1 | + | package copy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/base64" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func snapshot(lines []string, height int) Snapshot { | |||
| 12 | + | rows := make([][]renderer.Cell, len(lines)) | |||
| 13 | + | for i, line := range lines { | |||
| 14 | + | rows[i] = row(line) | |||
| 15 | + | } | |||
| 16 | + | return Snapshot{Rows: rows, Width: 8, Height: height} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestCopyModeNavigationBounds(t *testing.T) { | |||
| 20 | + | tests := []struct { | |||
| 21 | + | name string | |||
| 22 | + | lines []string | |||
| 23 | + | height int | |||
| 24 | + | ops func(*Mode, Snapshot) | |||
| 25 | + | wantCursor int | |||
| 26 | + | wantTop int | |||
| 27 | + | }{ | |||
| 28 | + | { | |||
| 29 | + | name: "initial viewport starts at bottom", | |||
| 30 | + | lines: []string{"00", "01", "02", "03", "04"}, | |||
| 31 | + | height: 3, | |||
| 32 | + | ops: func(*Mode, Snapshot) {}, | |||
| 33 | + | wantCursor: 4, | |||
| 34 | + | wantTop: 2, | |||
| 35 | + | }, | |||
| 36 | + | { | |||
| 37 | + | name: "k clamps at top and scrolls viewport", | |||
| 38 | + | lines: []string{"00", "01", "02", "03", "04"}, | |||
| 39 | + | height: 3, | |||
| 40 | + | ops: func(m *Mode, s Snapshot) { | |||
| 41 | + | m.Move(s, -99) | |||
| 42 | + | }, | |||
| 43 | + | wantCursor: 0, | |||
| 44 | + | wantTop: 0, | |||
| 45 | + | }, | |||
| 46 | + | { | |||
| 47 | + | name: "j clamps at bottom", | |||
| 48 | + | lines: []string{"00", "01", "02", "03", "04"}, | |||
| 49 | + | height: 3, | |||
| 50 | + | ops: func(m *Mode, s Snapshot) { | |||
| 51 | + | m.Move(s, 99) | |||
| 52 | + | }, | |||
| 53 | + | wantCursor: 4, | |||
| 54 | + | wantTop: 2, | |||
| 55 | + | }, | |||
| 56 | + | { | |||
| 57 | + | name: "page up moves by viewport height", | |||
| 58 | + | lines: []string{"00", "01", "02", "03", "04", "05", "06"}, | |||
| 59 | + | height: 3, | |||
| 60 | + | ops: func(m *Mode, s Snapshot) { | |||
| 61 | + | m.Page(s, -1) | |||
| 62 | + | }, | |||
| 63 | + | wantCursor: 3, | |||
| 64 | + | wantTop: 3, | |||
| 65 | + | }, | |||
| 66 | + | { | |||
| 67 | + | name: "g and G jump to document bounds", | |||
| 68 | + | lines: []string{"00", "01", "02", "03", "04"}, | |||
| 69 | + | height: 3, | |||
| 70 | + | ops: func(m *Mode, s Snapshot) { | |||
| 71 | + | m.Top(s) | |||
| 72 | + | m.Bottom(s) | |||
| 73 | + | }, | |||
| 74 | + | wantCursor: 4, | |||
| 75 | + | wantTop: 2, | |||
| 76 | + | }, | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | for _, tt := range tests { | |||
| 80 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 81 | + | s := snapshot(tt.lines, tt.height) | |||
| 82 | + | m := NewMode(s) | |||
| 83 | + | tt.ops(m, s) | |||
| 84 | + | if m.Cursor != tt.wantCursor || m.ViewportTop != tt.wantTop { | |||
| 85 | + | t.Fatalf("cursor/top = %d/%d, want %d/%d", m.Cursor, m.ViewportTop, tt.wantCursor, tt.wantTop) | |||
| 86 | + | } | |||
| 87 | + | }) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestCopyModeSelectionPayloadAndInverse(t *testing.T) { | |||
| 92 | + | s := snapshot([]string{"alpha ", "beta ", "gamma "}, 2) | |||
| 93 | + | m := &Mode{ViewportTop: 0, Cursor: 0, Anchor: 0, Selecting: true} | |||
| 94 | + | m.Move(s, 1) | |||
| 95 | + | ||||
| 96 | + | if got, want := m.SelectedText(s), "alpha\nbeta"; got != want { | |||
| 97 | + | t.Fatalf("SelectedText() = %q, want %q", got, want) | |||
| 98 | + | } | |||
| 99 | + | frame := m.Render(s) | |||
| 100 | + | for y := 0; y < 2; y++ { | |||
| 101 | + | if !frame.At(0, y).Style.Inverse { | |||
| 102 | + | t.Fatalf("row %d first cell not inverse", y) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestOSC52Base64EncodingAndChunking(t *testing.T) { | |||
| 108 | + | tests := []struct { | |||
| 109 | + | name string | |||
| 110 | + | text string | |||
| 111 | + | }{ | |||
| 112 | + | {name: "small payload", text: "hello\nworld"}, | |||
| 113 | + | {name: "chunked payload", text: strings.Repeat("x", OSC52ChunkSize)}, | |||
| 114 | + | } | |||
| 115 | + | for _, tt := range tests { | |||
| 116 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 117 | + | chunks := OSC52(tt.text) | |||
| 118 | + | joined := "" | |||
| 119 | + | for _, chunk := range chunks { | |||
| 120 | + | s := string(chunk) | |||
| 121 | + | if !strings.HasPrefix(s, "\x1b]52;c;") || !strings.HasSuffix(s, "\x07") { | |||
| 122 | + | t.Fatalf("chunk %q missing OSC52 wrapper", s) | |||
| 123 | + | } | |||
| 124 | + | joined += strings.TrimSuffix(strings.TrimPrefix(s, "\x1b]52;c;"), "\x07") | |||
| 125 | + | } | |||
| 126 | + | decoded, err := base64.StdEncoding.DecodeString(joined) | |||
| 127 | + | if err != nil { | |||
| 128 | + | t.Fatalf("DecodeString() error = %v", err) | |||
| 129 | + | } | |||
| 130 | + | if string(decoded) != tt.text { | |||
| 131 | + | t.Fatalf("decoded = %q, want %q", string(decoded), tt.text) | |||
| 132 | + | } | |||
| 133 | + | if tt.name == "chunked payload" && len(chunks) < 2 { | |||
| 134 | + | t.Fatalf("len(chunks) = %d, want chunking", len(chunks)) | |||
| 135 | + | } | |||
| 136 | + | }) | |||
| 137 | + | } | |||
| 138 | + | } | |||
M internal/usecase/daemon/daemon.go +124 -7
| @@ -143,11 +143,12 @@ type window struct { | |||
| 143 | 143 | // scheduler and the connection handler — so the transport's single-writer | |
| 144 | 144 | // contract holds and Draw→Send stays atomic (correct output ordering). | |
| 145 | 145 | type attachedClient struct { | |
| 146 | - | tr ports.Transport | |
| 147 | - | rend *renderer.Renderer | |
| 148 | - | size domain.Size | |
| 149 | - | keys *keys.Router | |
| 150 | - | sendMu sync.Mutex | |
| 146 | + | tr ports.Transport | |
| 147 | + | rend *renderer.Renderer | |
| 148 | + | size domain.Size | |
| 149 | + | keys *keys.Router | |
| 150 | + | copyMode *scopy.Mode | |
| 151 | + | sendMu sync.Mutex | |
| 151 | 152 | } | |
| 152 | 153 | ||
| 153 | 154 | // send serialises a frame onto the client's transport. | |
| @@ -679,12 +680,119 @@ func (s *session) activeWindow() *window { | |||
| 679 | 680 | } | |
| 680 | 681 | ||
| 681 | 682 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | |
| 683 | + | if ac.copyMode != nil { | |
| 684 | + | d.handleCopyInput(sess, ac, data) | |
| 685 | + | return | |
| 686 | + | } | |
| 682 | 687 | if ac.keys == nil { | |
| 683 | 688 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | |
| 684 | 689 | } | |
| 685 | 690 | ac.keys.Route(data) | |
| 686 | 691 | } | |
| 687 | 692 | ||
| 693 | + | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |
| 694 | + | win := sess.activeWindow() | |
| 695 | + | if win == nil { | |
| 696 | + | return | |
| 697 | + | } | |
| 698 | + | win.mu.Lock() | |
| 699 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 700 | + | win.mu.Unlock() | |
| 701 | + | ac.copyMode = scopy.NewMode(snap) | |
| 702 | + | d.paint(sess, ac, true) | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) { | |
| 706 | + | win := sess.activeWindow() | |
| 707 | + | if win == nil || ac.copyMode == nil { | |
| 708 | + | return | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | win.mu.Lock() | |
| 712 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 713 | + | changed := false | |
| 714 | + | copyOut := false | |
| 715 | + | exit := false | |
| 716 | + | for i := 0; i < len(data); i++ { | |
| 717 | + | switch data[i] { | |
| 718 | + | case 'j': | |
| 719 | + | ac.copyMode.Move(snap, 1) | |
| 720 | + | changed = true | |
| 721 | + | case 'k': | |
| 722 | + | ac.copyMode.Move(snap, -1) | |
| 723 | + | changed = true | |
| 724 | + | case 'g': | |
| 725 | + | ac.copyMode.Top(snap) | |
| 726 | + | changed = true | |
| 727 | + | case 'G': | |
| 728 | + | ac.copyMode.Bottom(snap) | |
| 729 | + | changed = true | |
| 730 | + | case ' ': | |
| 731 | + | ac.copyMode.ToggleSelection() | |
| 732 | + | changed = true | |
| 733 | + | case '\r', '\n', 'y': | |
| 734 | + | copyOut = true | |
| 735 | + | exit = true | |
| 736 | + | case 'q', 0x03, 0x1b: | |
| 737 | + | if data[i] == 0x1b { | |
| 738 | + | consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:]) | |
| 739 | + | if ok { | |
| 740 | + | i += consumed - 1 | |
| 741 | + | changed = true | |
| 742 | + | continue | |
| 743 | + | } | |
| 744 | + | } | |
| 745 | + | exit = true | |
| 746 | + | } | |
| 747 | + | } | |
| 748 | + | text := "" | |
| 749 | + | if copyOut { | |
| 750 | + | text = ac.copyMode.SelectedText(snap) | |
| 751 | + | } | |
| 752 | + | win.mu.Unlock() | |
| 753 | + | ||
| 754 | + | if copyOut { | |
| 755 | + | for _, chunk := range scopy.OSC52(text) { | |
| 756 | + | if err := ac.send(frameOutput(chunk)); err != nil { | |
| 757 | + | d.detachOnSendError(sess, ac) | |
| 758 | + | return | |
| 759 | + | } | |
| 760 | + | } | |
| 761 | + | } | |
| 762 | + | if exit { | |
| 763 | + | ac.copyMode = nil | |
| 764 | + | d.paint(sess, ac, true) | |
| 765 | + | return | |
| 766 | + | } | |
| 767 | + | if changed { | |
| 768 | + | d.paint(sess, ac, true) | |
| 769 | + | } | |
| 770 | + | } | |
| 771 | + | ||
| 772 | + | func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) { | |
| 773 | + | if len(data) >= 3 && data[1] == '[' { | |
| 774 | + | switch data[2] { | |
| 775 | + | case 'A': | |
| 776 | + | m.Move(snap, -1) | |
| 777 | + | return 3, true | |
| 778 | + | case 'B': | |
| 779 | + | m.Move(snap, 1) | |
| 780 | + | return 3, true | |
| 781 | + | } | |
| 782 | + | } | |
| 783 | + | if len(data) >= 4 && data[1] == '[' && data[3] == '~' { | |
| 784 | + | switch data[2] { | |
| 785 | + | case '5': | |
| 786 | + | m.Page(snap, -1) | |
| 787 | + | return 4, true | |
| 788 | + | case '6': | |
| 789 | + | m.Page(snap, 1) | |
| 790 | + | return 4, true | |
| 791 | + | } | |
| 792 | + | } | |
| 793 | + | return 0, false | |
| 794 | + | } | |
| 795 | + | ||
| 688 | 796 | type daemonKeyHandler struct { | |
| 689 | 797 | d *Daemon | |
| 690 | 798 | sess *session | |
| @@ -723,7 +831,7 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 723 | 831 | h.d.closeWindow(h.sess, win, true) | |
| 724 | 832 | } | |
| 725 | 833 | case keys.ActionCopyMode: | |
| 726 | - | // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted. | |
| 834 | + | h.d.enterCopyMode(h.sess, h.ac) | |
| 727 | 835 | case keys.ActionRenameSession: | |
| 728 | 836 | h.sess.promoteEphemeral() | |
| 729 | 837 | h.d.paint(h.sess, h.ac, true) | |
| @@ -893,10 +1001,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 893 | 1001 | ||
| 894 | 1002 | ac.sendMu.Lock() | |
| 895 | 1003 | win.mu.Lock() | |
| 896 | - | if reset { | |
| 1004 | + | if reset || ac.copyMode != nil { | |
| 897 | 1005 | ac.rend.Reset() | |
| 898 | 1006 | } | |
| 899 | 1007 | frame, damage := composeClientFrame(sess, win, reset) | |
| 1008 | + | if ac.copyMode != nil { | |
| 1009 | + | frame, damage = composeCopyClientFrame(ac.copyMode, win) | |
| 1010 | + | } | |
| 900 | 1011 | data, err := ac.rend.Draw(frame, damage) | |
| 901 | 1012 | win.screen.ClearDamage() | |
| 902 | 1013 | win.mu.Unlock() | |
| @@ -934,6 +1045,12 @@ func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, | |||
| 934 | 1045 | return frame, damage | |
| 935 | 1046 | } | |
| 936 | 1047 | ||
| 1048 | + | func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) { | |
| 1049 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |
| 1050 | + | frame := mode.Render(snap) | |
| 1051 | + | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 1052 | + | } | |
| 1053 | + | ||
| 937 | 1054 | func drawStatus(row []renderer.Cell, sess *session) { | |
| 938 | 1055 | for i := range row { | |
| 939 | 1056 | row[i] = renderer.BlankCell() | |
| @@ -143,11 +143,12 @@ type window struct { | |||||
| 143 | // scheduler and the connection handler — so the transport's single-writer | 143 | // scheduler and the connection handler — so the transport's single-writer | ||
| 144 | // contract holds and Draw→Send stays atomic (correct output ordering). | 144 | // contract holds and Draw→Send stays atomic (correct output ordering). | ||
| 145 | type attachedClient struct { | 145 | type attachedClient struct { | ||
| 146 | - | tr ports.Transport | 146 | + | tr ports.Transport |
| 147 | - | rend *renderer.Renderer | 147 | + | rend *renderer.Renderer |
| 148 | - | size domain.Size | 148 | + | size domain.Size |
| 149 | - | keys *keys.Router | 149 | + | keys *keys.Router |
| 150 | - | sendMu sync.Mutex | 150 | + | copyMode *scopy.Mode |
| 151 | + | sendMu sync.Mutex | |||
| 151 | } | 152 | } | ||
| 152 | 153 | ||||
| 153 | // send serialises a frame onto the client's transport. | 154 | // send serialises a frame onto the client's transport. | ||
| @@ -679,12 +680,119 @@ func (s *session) activeWindow() *window { | |||||
| 679 | } | 680 | } | ||
| 680 | 681 | ||||
| 681 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | 682 | func (d *Daemon) handleInput(sess *session, ac *attachedClient, data []byte) { | ||
| 683 | + | if ac.copyMode != nil { | |||
| 684 | + | d.handleCopyInput(sess, ac, data) | |||
| 685 | + | return | |||
| 686 | + | } | |||
| 682 | if ac.keys == nil { | 687 | if ac.keys == nil { | ||
| 683 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | 688 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, sess: sess, ac: ac}) | ||
| 684 | } | 689 | } | ||
| 685 | ac.keys.Route(data) | 690 | ac.keys.Route(data) | ||
| 686 | } | 691 | } | ||
| 687 | 692 | ||||
| 693 | + | func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) { | |||
| 694 | + | win := sess.activeWindow() | |||
| 695 | + | if win == nil { | |||
| 696 | + | return | |||
| 697 | + | } | |||
| 698 | + | win.mu.Lock() | |||
| 699 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |||
| 700 | + | win.mu.Unlock() | |||
| 701 | + | ac.copyMode = scopy.NewMode(snap) | |||
| 702 | + | d.paint(sess, ac, true) | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | func (d *Daemon) handleCopyInput(sess *session, ac *attachedClient, data []byte) { | |||
| 706 | + | win := sess.activeWindow() | |||
| 707 | + | if win == nil || ac.copyMode == nil { | |||
| 708 | + | return | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | win.mu.Lock() | |||
| 712 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |||
| 713 | + | changed := false | |||
| 714 | + | copyOut := false | |||
| 715 | + | exit := false | |||
| 716 | + | for i := 0; i < len(data); i++ { | |||
| 717 | + | switch data[i] { | |||
| 718 | + | case 'j': | |||
| 719 | + | ac.copyMode.Move(snap, 1) | |||
| 720 | + | changed = true | |||
| 721 | + | case 'k': | |||
| 722 | + | ac.copyMode.Move(snap, -1) | |||
| 723 | + | changed = true | |||
| 724 | + | case 'g': | |||
| 725 | + | ac.copyMode.Top(snap) | |||
| 726 | + | changed = true | |||
| 727 | + | case 'G': | |||
| 728 | + | ac.copyMode.Bottom(snap) | |||
| 729 | + | changed = true | |||
| 730 | + | case ' ': | |||
| 731 | + | ac.copyMode.ToggleSelection() | |||
| 732 | + | changed = true | |||
| 733 | + | case '\r', '\n', 'y': | |||
| 734 | + | copyOut = true | |||
| 735 | + | exit = true | |||
| 736 | + | case 'q', 0x03, 0x1b: | |||
| 737 | + | if data[i] == 0x1b { | |||
| 738 | + | consumed, ok := routeCopyEscape(ac.copyMode, snap, data[i:]) | |||
| 739 | + | if ok { | |||
| 740 | + | i += consumed - 1 | |||
| 741 | + | changed = true | |||
| 742 | + | continue | |||
| 743 | + | } | |||
| 744 | + | } | |||
| 745 | + | exit = true | |||
| 746 | + | } | |||
| 747 | + | } | |||
| 748 | + | text := "" | |||
| 749 | + | if copyOut { | |||
| 750 | + | text = ac.copyMode.SelectedText(snap) | |||
| 751 | + | } | |||
| 752 | + | win.mu.Unlock() | |||
| 753 | + | ||||
| 754 | + | if copyOut { | |||
| 755 | + | for _, chunk := range scopy.OSC52(text) { | |||
| 756 | + | if err := ac.send(frameOutput(chunk)); err != nil { | |||
| 757 | + | d.detachOnSendError(sess, ac) | |||
| 758 | + | return | |||
| 759 | + | } | |||
| 760 | + | } | |||
| 761 | + | } | |||
| 762 | + | if exit { | |||
| 763 | + | ac.copyMode = nil | |||
| 764 | + | d.paint(sess, ac, true) | |||
| 765 | + | return | |||
| 766 | + | } | |||
| 767 | + | if changed { | |||
| 768 | + | d.paint(sess, ac, true) | |||
| 769 | + | } | |||
| 770 | + | } | |||
| 771 | + | ||||
| 772 | + | func routeCopyEscape(m *scopy.Mode, snap scopy.Snapshot, data []byte) (int, bool) { | |||
| 773 | + | if len(data) >= 3 && data[1] == '[' { | |||
| 774 | + | switch data[2] { | |||
| 775 | + | case 'A': | |||
| 776 | + | m.Move(snap, -1) | |||
| 777 | + | return 3, true | |||
| 778 | + | case 'B': | |||
| 779 | + | m.Move(snap, 1) | |||
| 780 | + | return 3, true | |||
| 781 | + | } | |||
| 782 | + | } | |||
| 783 | + | if len(data) >= 4 && data[1] == '[' && data[3] == '~' { | |||
| 784 | + | switch data[2] { | |||
| 785 | + | case '5': | |||
| 786 | + | m.Page(snap, -1) | |||
| 787 | + | return 4, true | |||
| 788 | + | case '6': | |||
| 789 | + | m.Page(snap, 1) | |||
| 790 | + | return 4, true | |||
| 791 | + | } | |||
| 792 | + | } | |||
| 793 | + | return 0, false | |||
| 794 | + | } | |||
| 795 | + | ||||
| 688 | type daemonKeyHandler struct { | 796 | type daemonKeyHandler struct { | ||
| 689 | d *Daemon | 797 | d *Daemon | ||
| 690 | sess *session | 798 | sess *session | ||
| @@ -723,7 +831,7 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 723 | h.d.closeWindow(h.sess, win, true) | 831 | h.d.closeWindow(h.sess, win, true) | ||
| 724 | } | 832 | } | ||
| 725 | case keys.ActionCopyMode: | 833 | case keys.ActionCopyMode: | ||
| 726 | - | // M4 will install copy-mode state here; for M3 the binding is intentionally intercepted. | 834 | + | h.d.enterCopyMode(h.sess, h.ac) |
| 727 | case keys.ActionRenameSession: | 835 | case keys.ActionRenameSession: | ||
| 728 | h.sess.promoteEphemeral() | 836 | h.sess.promoteEphemeral() | ||
| 729 | h.d.paint(h.sess, h.ac, true) | 837 | h.d.paint(h.sess, h.ac, true) | ||
| @@ -893,10 +1001,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 893 | 1001 | ||||
| 894 | ac.sendMu.Lock() | 1002 | ac.sendMu.Lock() | ||
| 895 | win.mu.Lock() | 1003 | win.mu.Lock() | ||
| 896 | - | if reset { | 1004 | + | if reset || ac.copyMode != nil { |
| 897 | ac.rend.Reset() | 1005 | ac.rend.Reset() | ||
| 898 | } | 1006 | } | ||
| 899 | frame, damage := composeClientFrame(sess, win, reset) | 1007 | frame, damage := composeClientFrame(sess, win, reset) | ||
| 1008 | + | if ac.copyMode != nil { | |||
| 1009 | + | frame, damage = composeCopyClientFrame(ac.copyMode, win) | |||
| 1010 | + | } | |||
| 900 | data, err := ac.rend.Draw(frame, damage) | 1011 | data, err := ac.rend.Draw(frame, damage) | ||
| 901 | win.screen.ClearDamage() | 1012 | win.screen.ClearDamage() | ||
| 902 | win.mu.Unlock() | 1013 | win.mu.Unlock() | ||
| @@ -934,6 +1045,12 @@ func composeClientFrame(sess *session, win *window, full bool) (renderer.Frame, | |||||
| 934 | return frame, damage | 1045 | return frame, damage | ||
| 935 | } | 1046 | } | ||
| 936 | 1047 | ||||
| 1048 | + | func composeCopyClientFrame(mode *scopy.Mode, win *window) (renderer.Frame, []renderer.Damage) { | |||
| 1049 | + | snap := scopy.NewSnapshot(win.scrollback, win.screen.Frame) | |||
| 1050 | + | frame := mode.Render(snap) | |||
| 1051 | + | return frame, []renderer.Damage{renderer.FullRedraw()} | |||
| 1052 | + | } | |||
| 1053 | + | ||||
| 937 | func drawStatus(row []renderer.Cell, sess *session) { | 1054 | func drawStatus(row []renderer.Cell, sess *session) { | ||
| 938 | for i := range row { | 1055 | for i := range row { | ||
| 939 | row[i] = renderer.BlankCell() | 1056 | row[i] = renderer.BlankCell() | ||
M internal/usecase/daemon/daemon_test.go +94 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "io" | |
| 6 | 6 | "log/slog" | |
| 7 | + | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| 9 | 10 | "testing" | |
| @@ -15,6 +16,7 @@ import ( | |||
| 15 | 16 | "github.com/bnema/vev/internal/domain" | |
| 16 | 17 | "github.com/bnema/vev/internal/ports" | |
| 17 | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 19 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 20 | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 21 | "github.com/bnema/vev/pkg/vt" | |
| 20 | 22 | ) | |
| @@ -246,6 +248,98 @@ func activeWindowIndex(sess *session) int { | |||
| 246 | 248 | return sess.active | |
| 247 | 249 | } | |
| 248 | 250 | ||
| 251 | + | func testRow(text string) []renderer.Cell { | |
| 252 | + | cells := make([]renderer.Cell, 0, len(text)) | |
| 253 | + | for _, r := range text { | |
| 254 | + | cells = append(cells, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 255 | + | } | |
| 256 | + | return cells | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |
| 260 | + | writes := make(chan []byte, 1) | |
| 261 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 262 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 263 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 264 | + | sess.windows[0].screen.Write([]byte("live")) | |
| 265 | + | ||
| 266 | + | d.handleInput(sess, ac, []byte("\x1bu")) | |
| 267 | + | ||
| 268 | + | if ac.copyMode == nil { | |
| 269 | + | t.Fatal("copy mode not entered") | |
| 270 | + | } | |
| 271 | + | select { | |
| 272 | + | case got := <-writes: | |
| 273 | + | t.Fatalf("copy-mode binding forwarded to PTY: %q", got) | |
| 274 | + | default: | |
| 275 | + | } | |
| 276 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 277 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 278 | + | require.NoError(t, err) | |
| 279 | + | if !strings.Contains(string(msg.Data), "[COPY]") { | |
| 280 | + | t.Fatalf("copy mode paint = %q, want [COPY] status", string(msg.Data)) | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |
| 285 | + | writes := make(chan []byte, 1) | |
| 286 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |
| 287 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 288 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 289 | + | sess.windows[0].scrollback.Append(testRow("old1 ")) | |
| 290 | + | sess.windows[0].scrollback.Append(testRow("old2 ")) | |
| 291 | + | sess.windows[0].screen.Write([]byte("live")) | |
| 292 | + | ||
| 293 | + | d.enterCopyMode(sess, ac) | |
| 294 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 295 | + | d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'}) | |
| 296 | + | ||
| 297 | + | select { | |
| 298 | + | case got := <-writes: | |
| 299 | + | t.Fatalf("copy-mode navigation forwarded to PTY: %q", got) | |
| 300 | + | default: | |
| 301 | + | } | |
| 302 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 303 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 304 | + | require.NoError(t, err) | |
| 305 | + | if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want { | |
| 306 | + | t.Fatalf("OSC52 = %q, want %q", got, want) | |
| 307 | + | } | |
| 308 | + | if ac.copyMode != nil { | |
| 309 | + | t.Fatal("copy mode still active after yank") | |
| 310 | + | } | |
| 311 | + | live := awaitFrame(t, sends, ports.MsgOutput) | |
| 312 | + | liveMsg, err := ports.UnmarshalOutput(live.Payload) | |
| 313 | + | require.NoError(t, err) | |
| 314 | + | if strings.Contains(string(liveMsg.Data), "[COPY]") { | |
| 315 | + | t.Fatalf("live repaint still contains copy status: %q", string(liveMsg.Data)) | |
| 316 | + | } | |
| 317 | + | if !strings.Contains(string(liveMsg.Data), "live") { | |
| 318 | + | t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data)) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |
| 323 | + | p, _ := newBlockingPTY(t) | |
| 324 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 325 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |
| 326 | + | sess.windows[0].screen.Write([]byte("live")) | |
| 327 | + | ||
| 328 | + | d.enterCopyMode(sess, ac) | |
| 329 | + | awaitFrame(t, sends, ports.MsgOutput) | |
| 330 | + | d.handleInput(sess, ac, []byte("q")) | |
| 331 | + | ||
| 332 | + | if ac.copyMode != nil { | |
| 333 | + | t.Fatal("copy mode still active after q") | |
| 334 | + | } | |
| 335 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |
| 336 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |
| 337 | + | require.NoError(t, err) | |
| 338 | + | if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") { | |
| 339 | + | t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data)) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 249 | 343 | func windowCount(sess *session) int { | |
| 250 | 344 | sess.mu.Lock() | |
| 251 | 345 | defer sess.mu.Unlock() | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "io" | 5 | "io" | ||
| 6 | "log/slog" | 6 | "log/slog" | ||
| 7 | + | "strings" | |||
| 7 | "sync" | 8 | "sync" | ||
| 8 | "sync/atomic" | 9 | "sync/atomic" | ||
| 9 | "testing" | 10 | "testing" | ||
| @@ -15,6 +16,7 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/domain" | 16 | "github.com/bnema/vev/internal/domain" | ||
| 16 | "github.com/bnema/vev/internal/ports" | 17 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 18 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 19 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 18 | "github.com/bnema/vev/pkg/renderer" | 20 | "github.com/bnema/vev/pkg/renderer" | ||
| 19 | "github.com/bnema/vev/pkg/vt" | 21 | "github.com/bnema/vev/pkg/vt" | ||
| 20 | ) | 22 | ) | ||
| @@ -246,6 +248,98 @@ func activeWindowIndex(sess *session) int { | |||||
| 246 | return sess.active | 248 | return sess.active | ||
| 247 | } | 249 | } | ||
| 248 | 250 | ||||
| 251 | + | func testRow(text string) []renderer.Cell { | |||
| 252 | + | cells := make([]renderer.Cell, 0, len(text)) | |||
| 253 | + | for _, r := range text { | |||
| 254 | + | cells = append(cells, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 255 | + | } | |||
| 256 | + | return cells | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func TestCopyModeAltUInterceptsAndDoesNotForward(t *testing.T) { | |||
| 260 | + | writes := make(chan []byte, 1) | |||
| 261 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |||
| 262 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 263 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |||
| 264 | + | sess.windows[0].screen.Write([]byte("live")) | |||
| 265 | + | ||||
| 266 | + | d.handleInput(sess, ac, []byte("\x1bu")) | |||
| 267 | + | ||||
| 268 | + | if ac.copyMode == nil { | |||
| 269 | + | t.Fatal("copy mode not entered") | |||
| 270 | + | } | |||
| 271 | + | select { | |||
| 272 | + | case got := <-writes: | |||
| 273 | + | t.Fatalf("copy-mode binding forwarded to PTY: %q", got) | |||
| 274 | + | default: | |||
| 275 | + | } | |||
| 276 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 277 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 278 | + | require.NoError(t, err) | |||
| 279 | + | if !strings.Contains(string(msg.Data), "[COPY]") { | |||
| 280 | + | t.Fatalf("copy mode paint = %q, want [COPY] status", string(msg.Data)) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func TestCopyModeInputNotForwardedAndOSC52Copy(t *testing.T) { | |||
| 285 | + | writes := make(chan []byte, 1) | |||
| 286 | + | p, _ := newBlockingPTYWithWrites(t, writes) | |||
| 287 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 288 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |||
| 289 | + | sess.windows[0].scrollback.Append(testRow("old1 ")) | |||
| 290 | + | sess.windows[0].scrollback.Append(testRow("old2 ")) | |||
| 291 | + | sess.windows[0].screen.Write([]byte("live")) | |||
| 292 | + | ||||
| 293 | + | d.enterCopyMode(sess, ac) | |||
| 294 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 295 | + | d.handleInput(sess, ac, []byte{'g', ' ', 'j', 'y'}) | |||
| 296 | + | ||||
| 297 | + | select { | |||
| 298 | + | case got := <-writes: | |||
| 299 | + | t.Fatalf("copy-mode navigation forwarded to PTY: %q", got) | |||
| 300 | + | default: | |||
| 301 | + | } | |||
| 302 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 303 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 304 | + | require.NoError(t, err) | |||
| 305 | + | if got, want := string(msg.Data), "\x1b]52;c;b2xkMQpvbGQy\x07"; got != want { | |||
| 306 | + | t.Fatalf("OSC52 = %q, want %q", got, want) | |||
| 307 | + | } | |||
| 308 | + | if ac.copyMode != nil { | |||
| 309 | + | t.Fatal("copy mode still active after yank") | |||
| 310 | + | } | |||
| 311 | + | live := awaitFrame(t, sends, ports.MsgOutput) | |||
| 312 | + | liveMsg, err := ports.UnmarshalOutput(live.Payload) | |||
| 313 | + | require.NoError(t, err) | |||
| 314 | + | if strings.Contains(string(liveMsg.Data), "[COPY]") { | |||
| 315 | + | t.Fatalf("live repaint still contains copy status: %q", string(liveMsg.Data)) | |||
| 316 | + | } | |||
| 317 | + | if !strings.Contains(string(liveMsg.Data), "live") { | |||
| 318 | + | t.Fatalf("live repaint = %q, want live screen", string(liveMsg.Data)) | |||
| 319 | + | } | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | func TestCopyModeEscapeRestoresLiveFullRepaint(t *testing.T) { | |||
| 323 | + | p, _ := newBlockingPTY(t) | |||
| 324 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 325 | + | sess.windows[0].scrollback = scopy.NewScrollback(4) | |||
| 326 | + | sess.windows[0].screen.Write([]byte("live")) | |||
| 327 | + | ||||
| 328 | + | d.enterCopyMode(sess, ac) | |||
| 329 | + | awaitFrame(t, sends, ports.MsgOutput) | |||
| 330 | + | d.handleInput(sess, ac, []byte("q")) | |||
| 331 | + | ||||
| 332 | + | if ac.copyMode != nil { | |||
| 333 | + | t.Fatal("copy mode still active after q") | |||
| 334 | + | } | |||
| 335 | + | out := awaitFrame(t, sends, ports.MsgOutput) | |||
| 336 | + | msg, err := ports.UnmarshalOutput(out.Payload) | |||
| 337 | + | require.NoError(t, err) | |||
| 338 | + | if strings.Contains(string(msg.Data), "[COPY]") || !strings.Contains(string(msg.Data), "live") { | |||
| 339 | + | t.Fatalf("exit repaint = %q, want live full repaint without copy status", string(msg.Data)) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 249 | func windowCount(sess *session) int { | 343 | func windowCount(sess *session) int { | ||
| 250 | sess.mu.Lock() | 344 | sess.mu.Lock() | ||
| 251 | defer sess.mu.Unlock() | 345 | defer sess.mu.Unlock() | ||