返回 DeepSeek-Reasonix
composer_selection_test.go
根目录 / internal / cli / composer_selection_test.go
1 package cli
2
3 import (
4 "runtime"
5 "strconv"
6 "strings"
7 "testing"
8
9 tea "charm.land/bubbletea/v2"
10 "github.com/charmbracelet/x/ansi"
11
12 "reasonix/internal/control"
13 "reasonix/internal/event"
14 "reasonix/internal/i18n"
15 )
16
17 func newComposerMouseTestTUI(t *testing.T, width, height int) chatTUI {
18 t.Helper()
19 m := newChatTUI(control.New(control.Options{}), "", make(chan event.Event, 1), width)
20 next, _ := m.Update(tea.WindowSizeMsg{Width: width, Height: height})
21 return next.(chatTUI)
22 }
23
24 func updateComposerMouseTestTUI(t *testing.T, m chatTUI, msg tea.Msg) chatTUI {
25 t.Helper()
26 next, _ := m.Update(msg)
27 return next.(chatTUI)
28 }
29
30 func overflowingComposerMouseTestTUI(t *testing.T) chatTUI {
31 t.Helper()
32 m := newComposerMouseTestTUI(t, 50, 18)
33 lines := make([]string, 12)
34 for i := range lines {
35 lines[i] = "composer-line-" + strconv.Itoa(i)
36 }
37 m.input.SetValue(strings.Join(lines, "\n"))
38 return updateComposerMouseTestTUI(t, m, tea.WindowSizeMsg{Width: 50, Height: 18})
39 }
40
41 func TestComposerWheelScrollsViewWithoutMovingInsertionCursor(t *testing.T) {
42 m := overflowingComposerMouseTestTUI(t)
43 x, y, ok := m.composerOrigin()
44 if !ok {
45 t.Fatal("overflowing composer should expose a mouse origin")
46 }
47 inputOffset := m.input.ScrollYOffset()
48 row, column := m.input.Line(), m.input.Column()
49 transcriptOffset := m.viewport.YOffset()
50
51 m = updateComposerMouseTestTUI(t, m, tea.MouseWheelMsg{
52 X: x, Y: y, Button: tea.MouseWheelUp,
53 })
54
55 if !m.composerScrollDetached {
56 t.Fatal("wheel over overflowing composer should detach its visible viewport")
57 }
58 if got, want := m.composerViewOffset(), max(inputOffset-composerWheelRows, 0); got != want {
59 t.Fatalf("composer view offset = %d, want %d", got, want)
60 }
61 if got := m.input.ScrollYOffset(); got != inputOffset {
62 t.Fatalf("wheel moved textarea-owned offset to %d, want unchanged %d", got, inputOffset)
63 }
64 if m.input.Line() != row || m.input.Column() != column {
65 t.Fatalf("wheel moved insertion cursor to (%d,%d), want (%d,%d)", m.input.Line(), m.input.Column(), row, column)
66 }
67 if got := m.viewport.YOffset(); got != transcriptOffset {
68 t.Fatalf("composer wheel also moved transcript to %d, want %d", got, transcriptOffset)
69 }
70 firstVisible := strings.TrimSpace(strings.Split(ansi.Strip(m.renderComposerInput()), "\n")[0])
71 wantFirst := "composer-line-" + strconv.Itoa(m.composerViewOffset())
72 if firstVisible != wantFirst {
73 t.Fatalf("first visible composer row = %q, want %q", firstVisible, wantFirst)
74 }
75 if cur := m.composerCursor(); cur != nil {
76 t.Fatalf("cursor scrolled outside the composer should be hidden, got %+v", cur)
77 }
78 }
79
80 func TestComposerTypingRestoresCaretFollowingAfterWheel(t *testing.T) {
81 m := overflowingComposerMouseTestTUI(t)
82 x, y, _ := m.composerOrigin()
83 m = updateComposerMouseTestTUI(t, m, tea.MouseWheelMsg{
84 X: x, Y: y, Button: tea.MouseWheelUp,
85 })
86 if !m.composerScrollDetached {
87 t.Fatal("test setup should detach the composer viewport")
88 }
89
90 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: 'x', Text: "x"})
91 if m.composerScrollDetached {
92 t.Fatal("typing should restore caret-following composer viewport")
93 }
94 if got, want := m.composerViewOffset(), m.input.ScrollYOffset(); got != want {
95 t.Fatalf("reattached composer offset = %d, want textarea offset %d", got, want)
96 }
97 if !strings.HasSuffix(m.input.Value(), "x") {
98 t.Fatalf("typing after wheel did not edit at insertion cursor: %q", m.input.Value())
99 }
100 if cur := m.composerCursor(); cur == nil {
101 t.Fatal("caret-following should make the real cursor visible again")
102 }
103 }
104
105 func TestComposerWheelChainsToTranscriptAtInternalBoundary(t *testing.T) {
106 m := overflowingComposerMouseTestTUI(t)
107 notice := agentEventMsg(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "line"})
108 for range 40 {
109 m = updateComposerMouseTestTUI(t, m, notice)
110 }
111 if !m.viewport.AtBottom() {
112 t.Fatal("test transcript should start at bottom")
113 }
114 x, y, _ := m.composerOrigin()
115 wheelUp := tea.MouseWheelMsg{X: x, Y: y, Button: tea.MouseWheelUp}
116 for m.composerViewOffset() > 0 {
117 m = updateComposerMouseTestTUI(t, m, wheelUp)
118 }
119 bottom := m.viewport.YOffset()
120 if !m.viewport.AtBottom() {
121 t.Fatal("scrolling within composer should not move transcript before the boundary")
122 }
123
124 m = updateComposerMouseTestTUI(t, m, wheelUp)
125 if got, want := m.viewport.YOffset(), bottom-composerWheelRows; got != want {
126 t.Fatalf("wheel at composer top chained transcript to %d, want %d", got, want)
127 }
128 }
129
130 func TestComposerMouseClickMovesCursorAcrossWideRunes(t *testing.T) {
131 m := newComposerMouseTestTUI(t, 40, 12)
132 m.input.SetValue("你好abc")
133 x, y, ok := m.composerOrigin()
134 if !ok {
135 t.Fatal("composer should expose a mouse origin while visible")
136 }
137
138 // Each CJK rune occupies two terminal cells. Clicking after both should put
139 // the textarea cursor before the ASCII suffix, not leave it at the end.
140 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
141 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
142 if got := m.input.Column(); got != 2 {
143 t.Fatalf("cursor column = %d, want 2 after two wide runes", got)
144 }
145 if m.composerSel.active {
146 t.Fatal("a plain click should position the cursor without leaving a selection")
147 }
148 }
149
150 func TestComposerMouseLayoutRoundTripsTextareaCursor(t *testing.T) {
151 cases := []struct {
152 width int
153 value string
154 }{
155 {40, ""},
156 {20, "hello world and wrapped words"},
157 {16, "1234567890中文"},
158 {18, "alpha beta\n中文 mixed\n\nlast"},
159 {18, "one\ntwo\nthree\nfour\nfive\nsix\nseven"},
160 }
161 for _, tc := range cases {
162 m := newComposerMouseTestTUI(t, tc.width, 14)
163 m.input.SetValue(tc.value)
164 for offset := 0; offset <= len([]rune(tc.value)); offset++ {
165 m.setComposerCursor(offset)
166 local := m.input.Cursor()
167 x, y, ok := m.composerOrigin()
168 if !ok || local == nil {
169 t.Fatalf("value %q offset %d has no composer cursor", tc.value, offset)
170 }
171 caret, ok := m.composerCaretAt(x+local.X-composerPromptWidth, y+local.Y, false)
172 if !ok || caret.offset != offset {
173 t.Fatalf("value %q offset %d round-tripped to %+v (ok=%v)", tc.value, offset, caret, ok)
174 }
175 }
176 }
177 }
178
179 func TestComposerMouseDragSelectsAndTypingReplaces(t *testing.T) {
180 m := newComposerMouseTestTUI(t, 40, 12)
181 m.input.SetValue("你好abc")
182 x, y, _ := m.composerOrigin()
183
184 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
185 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
186 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
187
188 if got := m.selectedComposerText(); got != "你好" {
189 t.Fatalf("selected composer text = %q, want %q", got, "你好")
190 }
191 highlighted := m.renderComposerInput()
192 if !strings.Contains(highlighted, selStyle.Render("你好")) {
193 t.Fatalf("rendered composer should highlight exactly the selected wide runes: %q", highlighted)
194 }
195
196 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: 'X', Text: "X"})
197 if got := m.input.Value(); got != "Xabc" {
198 t.Fatalf("typing over selection produced %q, want %q", got, "Xabc")
199 }
200 if m.composerSel.active {
201 t.Fatal("typing over a selection should clear the selection")
202 }
203 }
204
205 func TestComposerMouseBackwardDragKeepsLogicalSelection(t *testing.T) {
206 m := newComposerMouseTestTUI(t, 40, 12)
207 m.input.SetValue("alpha beta")
208 x, y, _ := m.composerOrigin()
209
210 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
211 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 6, Y: y, Button: tea.MouseLeft})
212 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 6, Y: y, Button: tea.MouseLeft})
213 if got := m.selectedComposerText(); got != "beta" {
214 t.Fatalf("backward drag selected %q, want %q", got, "beta")
215 }
216 }
217
218 func TestComposerMouseSelectionSnapsToGraphemeClusters(t *testing.T) {
219 m := newComposerMouseTestTUI(t, 40, 12)
220 m.input.SetValue("e\u0301x 👨‍👩‍👧‍👦z")
221 x, y, _ := m.composerOrigin()
222
223 // The combining accent is a separate rune but part of the same one-cell
224 // grapheme, so a one-cell drag must select both runes together.
225 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
226 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 1, Y: y, Button: tea.MouseLeft})
227 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 1, Y: y, Button: tea.MouseLeft})
228 if got := m.selectedComposerText(); got != "e\u0301" {
229 t.Fatalf("combining grapheme selection = %q, want %q", got, "e\u0301")
230 }
231
232 // The family emoji contains seven runes but renders as one two-cell grapheme.
233 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 3, Y: y, Button: tea.MouseLeft})
234 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 5, Y: y, Button: tea.MouseLeft})
235 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 5, Y: y, Button: tea.MouseLeft})
236 if got := m.selectedComposerText(); got != "👨‍👩‍👧‍👦" {
237 t.Fatalf("emoji grapheme selection = %q, want family emoji", got)
238 }
239 }
240
241 func TestComposerSelectionTracksSoftWrapAndNewlines(t *testing.T) {
242 m := newComposerMouseTestTUI(t, 16, 14)
243 m.input.SetValue("1234567890中文\nsecond")
244 x, y, _ := m.composerOrigin()
245
246 // The configured textarea content width is 12 cells. Drag from the final two
247 // ASCII characters on the first visual row through the two wide CJK runes on
248 // the wrapped row and into the explicit second logical line.
249 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
250 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 3, Y: y + 2, Button: tea.MouseLeft})
251 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 3, Y: y + 2, Button: tea.MouseLeft})
252
253 if got, want := m.selectedComposerText(), "中文\nsec"; got != want {
254 t.Fatalf("wrapped multi-line selection = %q, want %q", got, want)
255 }
256
257 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: tea.KeyBackspace})
258 if got, want := m.input.Value(), "1234567890ond"; got != want {
259 t.Fatalf("backspace over wrapped selection = %q, want %q", got, want)
260 }
261 }
262
263 func TestComposerSelectionPasteAndCopyTakePrecedence(t *testing.T) {
264 m := newComposerMouseTestTUI(t, 40, 12)
265 m.input.SetValue("alpha beta")
266 x, y, _ := m.composerOrigin()
267 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 6, Y: y, Button: tea.MouseLeft})
268 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
269 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
270
271 next, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
272 m = next.(chatTUI)
273 if cmd == nil {
274 t.Fatal("Ctrl+C with a composer selection should issue a clipboard command")
275 }
276 if got := m.input.Value(); got != "alpha beta" {
277 t.Fatalf("Ctrl+C changed composer value to %q", got)
278 }
279 if got := m.selectedComposerText(); got != "beta" {
280 t.Fatalf("Ctrl+C should preserve composer selection, got %q", got)
281 }
282
283 m = updateComposerMouseTestTUI(t, m, tea.PasteMsg{Content: "gamma"})
284 if got := ansi.Strip(m.input.Value()); got != "alpha gamma" {
285 t.Fatalf("paste over selection produced %q, want %q", got, "alpha gamma")
286 }
287 }
288
289 func TestComposerDragReleaseAutoCopies(t *testing.T) {
290 setLocalClipboardSession(t)
291 m := newComposerMouseTestTUI(t, 40, 12)
292 m.input.SetValue("alpha beta")
293 x, y, _ := m.composerOrigin()
294 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 6, Y: y, Button: tea.MouseLeft})
295 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
296
297 previous := writeNativeClipboardText
298 t.Cleanup(func() { writeNativeClipboardText = previous })
299 writeNativeClipboardText = func(text string) error {
300 if text != "beta" {
301 t.Fatalf("composer drag copied %q, want beta", text)
302 }
303 return nil
304 }
305
306 next, cmd := m.Update(tea.MouseReleaseMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
307 m = next.(chatTUI)
308 if cmd == nil {
309 t.Fatal("composer drag release should copy the selected text")
310 }
311 if got := m.selectedComposerText(); got != "beta" {
312 t.Fatalf("composer selection after drag copy = %q, want beta", got)
313 }
314
315 result := clipboardCopyResultFromCmd(t, cmd)
316 next, _ = m.Update(result)
317 m = next.(chatTUI)
318 if m.copyNoticeText != i18n.M.MouseCopiedHint {
319 t.Fatalf("composer drag copy notice = %q, want %q", m.copyNoticeText, i18n.M.MouseCopiedHint)
320 }
321 }
322
323 func TestComposerPlainClickReleaseDoesNotCopy(t *testing.T) {
324 m := newComposerMouseTestTUI(t, 40, 12)
325 m.input.SetValue("alpha beta")
326 x, y, _ := m.composerOrigin()
327 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 3, Y: y, Button: tea.MouseLeft})
328
329 next, cmd := m.Update(tea.MouseReleaseMsg{X: x + 3, Y: y, Button: tea.MouseLeft})
330 m = next.(chatTUI)
331 if cmd != nil {
332 t.Fatal("plain composer click must not copy an empty selection")
333 }
334 if m.validComposerSelection() {
335 t.Fatal("plain composer click should clear its empty selection")
336 }
337 }
338
339 func TestComposerSelectionDoesNotTurnCommandShortcutIntoText(t *testing.T) {
340 m := newComposerMouseTestTUI(t, 40, 12)
341 m.input.SetValue("keep this")
342 x, y, _ := m.composerOrigin()
343 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
344 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
345 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
346
347 // Ctrl+Y is an application command and must not replace the selected draft.
348 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: 'y', Mod: tea.ModCtrl})
349 if got := m.input.Value(); got != "keep this" {
350 t.Fatalf("Ctrl+Y changed selected composer text to %q", got)
351 }
352 }
353
354 func TestComposerImagePasteShortcutKeepsSelectionUntilImageArrives(t *testing.T) {
355 m := newComposerMouseTestTUI(t, 40, 12)
356 m.input.SetValue("alpha beta")
357 x, y, _ := m.composerOrigin()
358 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + 6, Y: y, Button: tea.MouseLeft})
359 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
360 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 10, Y: y, Button: tea.MouseLeft})
361
362 shortcut := tea.KeyPressMsg{Code: 'v', Mod: tea.ModCtrl}
363 shortcutName := "Ctrl+V"
364 if runtime.GOOS == "windows" {
365 shortcut.Mod = tea.ModAlt
366 shortcutName = "Alt+V"
367 }
368 // The platform image-only shortcut must preserve the selection until the
369 // asynchronous attachment result can replace it.
370 next, cmd := m.Update(shortcut)
371 m = next.(chatTUI)
372 if cmd == nil {
373 t.Fatalf("%s should issue an async image clipboard read", shortcutName)
374 }
375 if !m.clipboardImagePending {
376 t.Fatalf("%s should show the image paste as pending", shortcutName)
377 }
378 if got := m.selectedComposerText(); got != "beta" {
379 t.Fatalf("%s should keep the selection until the clipboard arrives, got %q", shortcutName, got)
380 }
381
382 m = updateComposerMouseTestTUI(t, m, clipboardImageMsg{path: ".reasonix/attachments/test.png"})
383 if got := m.input.Value(); got != "alpha [image #1] " {
384 t.Fatalf("image paste over selection produced %q, want %q", got, "alpha [image #1] ")
385 }
386 if m.validComposerSelection() && !m.composerSel.empty() {
387 t.Fatal("image paste should consume the selection")
388 }
389 if m.clipboardImagePending {
390 t.Fatal("image result should clear the pending state")
391 }
392 }
393
394 func TestImagePasteShortcutIsDistinctFromTerminalTextPaste(t *testing.T) {
395 tests := []struct {
396 key string
397 goos string
398 want bool
399 }{
400 {key: "ctrl+v", goos: "darwin", want: true},
401 {key: "ctrl+v", goos: "linux", want: true},
402 {key: "ctrl+v", goos: "windows", want: false},
403 {key: "alt+v", goos: "windows", want: true},
404 {key: "alt+v", goos: "darwin", want: false},
405 {key: "ctrl+shift+v", goos: "linux", want: false},
406 {key: "super+v", goos: "darwin", want: false},
407 {key: "meta+v", goos: "darwin", want: false},
408 }
409 for _, tt := range tests {
410 if got := imagePasteShortcut(tt.key, tt.goos); got != tt.want {
411 t.Errorf("imagePasteShortcut(%q, %q) = %v, want %v", tt.key, tt.goos, got, tt.want)
412 }
413 }
414 }
415
416 func TestComposerArrowKeysCollapseSelection(t *testing.T) {
417 m := newComposerMouseTestTUI(t, 40, 12)
418 m.input.SetValue("alpha beta")
419 x, y, _ := m.composerOrigin()
420 drag := func(m chatTUI, from, to int) chatTUI {
421 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x + from, Y: y, Button: tea.MouseLeft})
422 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + to, Y: y, Button: tea.MouseLeft})
423 return updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + to, Y: y, Button: tea.MouseLeft})
424 }
425
426 // Left/Right collapse to the ordered selection start/end without moving
427 // an extra character, for forward and backward drags alike.
428 m = drag(m, 6, 10)
429 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
430 if m.composerSel.active {
431 t.Fatal("Left should dismiss the selection")
432 }
433 if got := m.input.Column(); got != 6 {
434 t.Fatalf("Left collapsed cursor to column %d, want 6 (selection start)", got)
435 }
436
437 m = drag(m, 6, 10)
438 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
439 if got := m.input.Column(); got != 10 {
440 t.Fatalf("Right collapsed cursor to column %d, want 10 (selection end)", got)
441 }
442
443 m = drag(m, 10, 6)
444 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: tea.KeyLeft})
445 if got := m.input.Column(); got != 6 {
446 t.Fatalf("Left after backward drag collapsed to column %d, want 6", got)
447 }
448
449 m = drag(m, 10, 6)
450 m = updateComposerMouseTestTUI(t, m, tea.KeyPressMsg{Code: tea.KeyRight})
451 if got := m.input.Column(); got != 10 {
452 t.Fatalf("Right after backward drag collapsed to column %d, want 10", got)
453 }
454
455 if got := m.input.Value(); got != "alpha beta" {
456 t.Fatalf("arrow keys changed composer value to %q", got)
457 }
458 }
459
460 func TestComposerNewlineSelectionHighlightsTrailingSpace(t *testing.T) {
461 m := newComposerMouseTestTUI(t, 40, 12)
462 m.input.SetValue("abc\ndef")
463
464 // Selecting only the newline must highlight the caret space after the
465 // line, not the preceding character.
466 m.composerSel = composerSelection{active: true, anchor: 3, head: 4, value: m.input.Value()}
467 highlighted := m.renderComposerInput()
468 if strings.Contains(highlighted, selStyle.Render("c")) {
469 t.Fatalf("newline-only selection must not highlight the previous character: %q", highlighted)
470 }
471 if !strings.Contains(highlighted, "abc"+selStyle.Render(" ")) {
472 t.Fatalf("newline-only selection should highlight the trailing caret space: %q", highlighted)
473 }
474
475 // A selection covering content plus the newline extends one cell past it.
476 m.composerSel = composerSelection{active: true, anchor: 2, head: 4, value: m.input.Value()}
477 highlighted = m.renderComposerInput()
478 if !strings.Contains(highlighted, selStyle.Render("c ")) {
479 t.Fatalf("selecting the last char plus newline should highlight both cells: %q", highlighted)
480 }
481 }
482
483 func TestClipboardPasteOverWideSelectionRequestsClearScreen(t *testing.T) {
484 prev := clearWideInputChanges
485 clearWideInputChanges = true
486 defer func() { clearWideInputChanges = prev }()
487
488 m := newComposerMouseTestTUI(t, 40, 12)
489 m.input.SetValue("你好abc")
490 x, y, _ := m.composerOrigin()
491 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
492 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
493 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
494
495 next, cmd := m.Update(tea.PasteMsg{Content: "hi"})
496 m = next.(chatTUI)
497 if got := m.input.Value(); got != "hiabc" {
498 t.Fatalf("clipboard paste over wide selection produced %q, want %q", got, "hiabc")
499 }
500 if cmd == nil {
501 t.Fatal("replacing a wide selection should request a full redraw")
502 }
503 }
504
505 func TestFailedImagePastePreservesComposerSelection(t *testing.T) {
506 m := newComposerMouseTestTUI(t, 40, 12)
507 m.input.SetValue("keep this")
508 x, y, _ := m.composerOrigin()
509 m = updateComposerMouseTestTUI(t, m, tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})
510 m = updateComposerMouseTestTUI(t, m, tea.MouseMotionMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
511 m = updateComposerMouseTestTUI(t, m, tea.MouseReleaseMsg{X: x + 4, Y: y, Button: tea.MouseLeft})
512
513 m = updateComposerMouseTestTUI(t, m, tea.PasteMsg{Content: "/path/that/does/not/exist.png"})
514 if got := m.input.Value(); got != "keep this" {
515 t.Fatalf("failed image paste changed composer value to %q", got)
516 }
517 if got := m.selectedComposerText(); got != "keep" {
518 t.Fatalf("failed image paste should preserve selection, got %q", got)
519 }
520 }
521
521 lines GO