| 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 |