detached 23ebd52

feat(terminal): preserve resize content and add visual selection

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close