Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
5c99107
feat(ui): word-wise drag after double-click, line-wise after triple-c…
qwen-code-dev-bot Aug 8, 2026
01404d5
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 8, 2026
99af051
fix(ui): copy single-cell word/line selections on release
qwen-code-dev-bot Aug 8, 2026
24ae25c
fix(ui): apply release cell to word/line drags, keep multi-click chain
qwen-code-dev-bot Aug 8, 2026
797f345
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 8, 2026
a6c516e
refactor(ui): dedupe word/line drag state and span dispatch
qwen-code-dev-bot Aug 8, 2026
7cf9520
refactor(ui): dedupe collapsed-range policy and pin multi-click tests
qwen-code-dev-bot Aug 8, 2026
b5cbd2f
test(ui): pin bare-click highlight and multi-row line-drag copy (#8739)
qwen-code-dev-bot Aug 9, 2026
4fd9868
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 9, 2026
a03e911
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 10, 2026
de9c6a1
fix(ui): restore press-time copy for multi-click selection (#8739)
qwen-code-dev-bot Aug 10, 2026
133e7ff
Merge branch 'feat/word-line-drag-selection' of https://github.com/Qw…
qwen-code-dev-bot Aug 10, 2026
b32dbbe
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
d5999ed
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
aaaae0d
Merge branch 'main' into feat/word-line-drag-selection
qwen-code-dev-bot Aug 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion packages/cli/src/ui/selection/selection-span.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
*/

import type { ReadonlyFrame } from 'ink';
import type { NormalizedSelection } from './selection-state.js';
import type {
NormalizedSelection,
Point,
SelectionMode,
} from './selection-state.js';

/** A cell counts as part of a word when it is non-empty and not whitespace. */
function isWordCell(value: string): boolean {
Expand Down Expand Up @@ -72,3 +76,14 @@ export function lineSpanAt(
}
return { sx: 0, sy: y, ex: end, ey: y };
}

/** Resolve the span at a point for a word/line selection mode. */
export function spanAtForMode(
frame: ReadonlyFrame | null,
mode: Exclude<SelectionMode, 'char'>,
point: Point,
): NormalizedSelection | null {
Comment on lines +81 to +85

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] spanAtForMode accepts the full SelectionMode union but only implements two of its three values: 'char' is silently routed to lineSpanAt (a whole-line span), contradicting the doc comment ("word/line selection mode"). The multi-click call site is statically safe (literal 'word' | 'line'), but extendSpanDrag passes selection.mode through un-narrowed, guarded only by the comment-documented invariant that anchorSpanRef non-null implies word/line mode. — Failure scenario: a future caller forwarding selection.mode from a char-mode path (e.g. lifting the anchorSpanRef guard while simplifying, or reusing this helper for a new snap feature) gets a whole-line span for a char-mode selection with zero compiler diagnostics — a character-level operation silently selects and copies an entire line. Suggested fix: narrow the parameter to Exclude<SelectionMode, 'char'>; extendSpanDrag then has to narrow selection.mode at the call site, surfacing the invariant at compile time instead of in a comment.

中文说明

(建议) spanAtForMode 接受完整的 SelectionMode 联合类型,但只实现了其中两个值:'char' 会被静默路由到 lineSpanAt(整行 span),与文档注释("word/line selection mode")矛盾。多点点击调用点是静态安全的(字面量 'word' | 'line'),但 extendSpanDrag 未收窄就传入 selection.mode,仅靠注释记载的不变式(anchorSpanRef 非空 ⟹ word/line 模式)保护。触发场景:未来若有调用方从 char 模式路径转发 selection.mode(例如简化时移除 anchorSpanRef 守卫,或为新吸附功能复用此 helper),char 模式选区会拿到整行 span 且编译器零报错——字符级操作会静默选中并复制整行。建议修复:把参数收窄为 Exclude<SelectionMode, 'char'>extendSpanDrag 就必须在调用点显式收窄 selection.mode,让不变式由编译器而不是注释来保证。

— qwen3.8-max via Qwen Code /review (v0.21.8)

return mode === 'word'
? wordSpanAt(frame, point.x, point.y)
: lineSpanAt(frame, point.y);
}
19 changes: 8 additions & 11 deletions packages/cli/src/ui/selection/selection-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,17 +46,6 @@ export class SelectionState {
}
}

/** Select a resolved word/line span from a multi-click (not a drag). */
selectSpan(
span: { sx: number; sy: number; ex: number; ey: number },
mode: SelectionMode,
): void {
this.anchor = { x: span.sx, y: span.sy };
this.focus = { x: span.ex, y: span.ey };
this.dragging = false;
this.mode = mode;
}

finish(): void {
this.dragging = false;
}
Expand All @@ -81,6 +70,14 @@ export class SelectionState {
);
}

/**
* A collapsed range is a real single-cell span in word/line mode, but only a
* bare click in char mode.
*/
get isBareClick(): boolean {
return this.isCollapsed && this.mode === 'char';
}

/** Anchor/focus ordered into reading order, or null when empty. */
normalized(): NormalizedSelection | null {
if (!this.anchor || !this.focus) {
Expand Down
280 changes: 280 additions & 0 deletions packages/cli/src/ui/selection/use-text-selection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,291 @@ describe('TextSelectionController', () => {
selectHello(handler);

handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1));

expect(copyToClipboard).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenLastCalledWith('hello');
});

it('does not highlight a bare char-mode click', () => {
const handler = mount();
handler(makeEvent('left-press', 1));

// Assert on press: release clears the highlight either way, so only the
// press call pins the bare-click suppression.
expect(setSelection).toHaveBeenLastCalledWith(null);

handler(makeEvent('left-release', 1));

expect(setSelection).toHaveBeenLastCalledWith(null);
expect(copyToClipboard).not.toHaveBeenCalled();
});

it('extends a double-click word selection word-wise on drag', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2)); // first click on "foo"
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 2,
ey: 0,
});
handler(makeEvent('move', 10)); // drag to "baz"
handler(makeEvent('left-release', 10));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
// The press-time copy survives so a repaint before release cannot lose
// the word; the release overwrites it with the grown range.
expect(copyToClipboard).toHaveBeenCalledWith('foo');
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The tests never pin that the copy happens only on release — the central behavior change of this PR (the diff deletes copySelection() from the multi-click press path). toHaveBeenCalledWith passes regardless of call count or timing.

Failure scenario: a mutant that re-adds copySelection() after applyHighlight() in the count >= 2 press branch passes every assertion (press copies foo, release copies foo bar baz, toHaveBeenCalledWith matches). Observable cost: the clipboard is written at double-click time even when the user never completes the gesture (double-click then scroll away), silently restoring the pre-PR behavior the diff's own comment declares removed.

Suggested change
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
expect(copyToClipboard).toHaveBeenCalledTimes(1);
中文说明

[建议] 测试没有钉住“复制只发生在释放时”这一行为——而这正是本 PR 的核心行为变更(diff 从多点点击的按下分支删掉了 copySelection())。toHaveBeenCalledWith 不关心调用次数和时机,怎么调用都能通过。

失败场景:在 count >= 2 按下分支 applyHighlight() 之后重新加回 copySelection() 的变异可以通过全部断言(按下复制 foo,释放复制 foo bar baztoHaveBeenCalledWith 依然匹配)。可观察的代价:用户即使没有完成手势(双击后滚走),剪贴板也已在双击时被写入——悄悄恢复了 diff 注释宣称已移除的旧行为。

— qwen3.8-max via Qwen Code /review (v0.21.7)

expect(copyToClipboard).toHaveBeenCalledTimes(2);
});

it('extends a triple-click line selection line-wise on drag', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The line-mode collapsed branch of the new release condition has no test: nothing ever produces a collapsed line selection (triple-click on a one-character line). Probe-verified at HEAD: the mutant selection.mode === 'char'selection.mode !== 'word' changes only that case (collapsed line cleared without copying) and the suite stays green. — Failure scenario: triple-click a line whose only content is one cell (a y answer, a single digit) — lineSpanAt returns a collapsed 'line'-mode range that must still copy; a refactor of the condition could silently drop the copy with no test failing. Suggested fix: add a sibling of the single-char-word test:

it('copies a one-cell line on a no-drag triple-click', () => {
  frame = makeFrame('x');
  viewportRect = { x: 0, y: 0, width: 1, height: 1 };
  const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
  const handler = mount();
  handler(makeEvent('left-press', 1));
  handler(makeEvent('left-press', 1));
  handler(makeEvent('left-press', 1)); // triple-click -> selects the line
  handler(makeEvent('left-release', 1));
  nowSpy.mockRestore();

  expect(setSelection).toHaveBeenLastCalledWith({
    sx: 0,
    sy: 0,
    ex: 0,
    ey: 0,
  });
  expect(copyToClipboard).toHaveBeenLastCalledWith('x');
});
中文说明

(建议) 新释放条件的 line 模式折叠分支没有测试:没有任何测试产生过折叠的 line 选区(对单字符行三击)。已在 HEAD 用探针验证:变异体 selection.mode === 'char'selection.mode !== 'word' 只影响该情形(折叠的 line 选区被清除而不复制),测试套件仍全绿。触发场景:三击只有一个单元格的行(如 y 回答、单个数字)——lineSpanAt 返回 'line' 模式的折叠选区,仍应复制;重构该条件时可能静默丢掉这次复制而无测试失败。建议修复:仿照单字符词测试补一条(代码同上)。

— qwen3.8-max via Qwen Code /review (v0.21.8)

frame = makeTwoLineFrame('hello', 'world!');
viewportRect = { x: 0, y: 0, width: 6, height: 2 };
Comment on lines +261 to +263

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The line-wise drag tests only use single-word lines ('hello', 'world!'), where lineSpanAt and wordSpanAt return identical spans — probe-verified at HEAD: replacing lineSpanAt with wordSpanAt in extendSpanDrag's line branch keeps all 17 tests green, while a distinguishing multi-word probe test flips between the mutant and the correct code — Failure scenario: triple-click a multi-word line ('foo bar') and drag into 'baz' on the next line: line-wise must copy 'foo bar\nbaz qux', word-wise copies only 'foo bar\nbaz'; if a refactor regresses triple-click drags to word granularity, every test this PR adds stays green while users get truncated clipboard content. Add a line-drag test over multi-word lines — the drag must land on a word that does not touch the line end (landing on 'qux' does not kill the mutant):

it('extends a triple-click line selection across multi-word lines', () => {
  frame = makeTwoLineFrame('foo bar', 'baz qux');
  viewportRect = { x: 0, y: 0, width: 7, height: 2 };
  const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
  const handler = mount();
  handler(makeEvent('left-press', 2, 1));
  handler(makeEvent('left-press', 2, 1));
  handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0
  handler(makeEvent('move', 2, 2)); // drag into 'baz' on line 1
  handler(makeEvent('left-release', 2, 2));
  nowSpy.mockRestore();

  expect(setSelection).toHaveBeenLastCalledWith({
    sx: 0,
    sy: 0,
    ex: 6,
    ey: 1,
  });
  expect(copyToClipboard).toHaveBeenCalledWith('foo bar\nbaz qux');
});
中文说明

(建议) 按行拖动的测试只使用单词行('hello'、'world!'),此时 lineSpanAtwordSpanAt 返回完全相同的 span——已在 HEAD 用探针验证:把 extendSpanDrag 行分支里的 lineSpanAt 换成 wordSpanAt,全部 17 条测试依旧通过;而一条可区分的多词行探针测试能在变异体与正确代码之间翻转结果。触发场景:三击选中多词行('foo bar')后拖到下一行的 'baz':按行应复制 'foo bar\nbaz qux',按词只会复制 'foo bar\nbaz';若未来重构把三击拖动退化为按词粒度,本 PR 新增的全部测试仍为绿色,用户却会复制到被截断的内容。建议补一条多词行的按行拖动测试——拖动落点必须选在不贴住行尾的词上(落在 'qux' 上杀不死该变异体)。

— qwen3.8-max via Qwen Code /review (v0.21.8)

const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1));
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1)); // double-click -> word "hello"
handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0
handler(makeEvent('move', 3, 2)); // drag into the middle of line 1
handler(makeEvent('left-release', 3, 2));
Comment on lines +266 to +272

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The triple-click tests emit three consecutive left-press events with no intervening releases, but SGR button tracking always delivers press/release pairs — this stream cannot occur in production. Probe-verified with a realistic press/release ×3 stream: copyToClipboard is called twice (word, then line, e.g. ['foo'], ['foo bar baz']) — A/B-verified identical at the merge base, so the double copy is pre-existing behavior this PR inherits rather than introduces; the in-scope problem is that toHaveBeenCalledTimes(1) passes only under the synthetic no-release stream, so the suite asserts one-copy-per-gesture for an impossible event sequence. — Failure scenario: on a real triple-click the intermediate release copies the word before the third click selects the line: clipboard managers record a spurious intermediate entry, and if the final copy fails (clipboard contention; the failure is only debug-logged) the clipboard silently retains the word instead of the line. Suggested fix: emit realistic press/release pairs in the triple-click tests and assert the final payload (toHaveBeenLastCalledWith) instead of a write count of 1.

中文说明

(建议) 三击测试连发三个 left-press 事件、中间没有释放,但 SGR 按键追踪总是成对上报 press/release——该事件流在生产中不可能出现。已用真实的 press/release ×3 事件流探针验证:copyToClipboard 被调用两次(先词后行,如 ['foo']['foo bar baz'])——并经 A/B 验证与合并基点完全一致,因此双写复制是本 PR 继承的既有行为而非新引入;范围内的问题是 toHaveBeenCalledTimes(1) 只在合成的无释放事件流下通过,即套件对一个不可能出现的事件序列断言了"每次手势只复制一次"。触发场景:真实三击时,中间那次释放会先复制词,第三击才选中行——剪贴板管理器会记录多余的中间条目;若最后一次复制失败(剪贴板被占用,失败仅记录 debug 日志),剪贴板会静默停留在词而不是行。建议修复:三击测试改用真实的 press/release 成对事件,并断言最终内容(toHaveBeenLastCalledWith)而不是复制次数为 1。

— qwen3.8-max via Qwen Code /review (v0.21.8)

nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 5,
ey: 1,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('hello\nworld!');
});

it('extends a triple-click line selection across multi-word lines', () => {
frame = makeTwoLineFrame('foo bar', 'baz qux');
viewportRect = { x: 0, y: 0, width: 7, height: 2 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1));
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1)); // double-click -> word "foo"
handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0
handler(makeEvent('move', 2, 2)); // drag into 'baz' on line 1
handler(makeEvent('left-release', 2, 2));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 6,
ey: 1,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('foo bar\nbaz qux');
});

it('copies a single-character word on a no-drag double-click', () => {
frame = makeFrame('a b');
viewportRect = { x: 0, y: 0, width: 3, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1));
handler(makeEvent('left-press', 1)); // double-click -> selects "a"
handler(makeEvent('left-release', 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 0,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('a');
expect(copyToClipboard).toHaveBeenCalledTimes(2);
});

it('keeps the double-click copy when streaming clears the selection before release', () => {
frame = makeFrame('foo bar');
viewportRect = { x: 0, y: 0, width: 7, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2));
handler(makeEvent('left-release', 2));
handler(makeEvent('left-press', 2)); // double-click -> copies "foo"
listener!(makeFrame('foo baz')); // streaming repaint clears the selection
handler(makeEvent('left-release', 2)); // release arrives after the clear
nowSpy.mockRestore();

expect(copyToClipboard).toHaveBeenCalledTimes(1);
expect(copyToClipboard).toHaveBeenCalledWith('foo');
});

it('copies a one-cell line on a no-drag triple-click', () => {
frame = makeFrame('x');
viewportRect = { x: 0, y: 0, width: 1, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1));
handler(makeEvent('left-press', 1));
handler(makeEvent('left-release', 1)); // double-click -> word "x"
handler(makeEvent('left-press', 1)); // triple-click -> line "x"
handler(makeEvent('left-release', 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 0,
ey: 0,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('x');

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The press-time copy guarantee this PR introduces is pinned only for double-click; the line-mode (triple-click) twin is missing. Probe-verified at HEAD: a mutant gating the press-time copy on count === 2 passes the full 22/22 suite — this test asserts only toHaveBeenLastCalledWith('x'), which holds no matter which event performed the count-3 copy.

Failure scenario: a regression restricting the press-time copy to word mode ships green; then a triple-click whose streaming repaint lands between press and release (clearSelectiondragging=false → the release early-returns without copying) copies nothing even though the user saw the line selected.

Add 'keeps the triple-click copy when streaming clears the selection before release' mirroring the existing double-click test, or assert the exact call count (4) here.

中文说明

(建议) 本 PR 引入的"按压时复制"保证只为双击固定了测试;行模式(三击)的对应测试缺失。已在 HEAD 用探针验证:把按压复制限定为 count === 2 的变异体能通过全部 22/22 测试——本测试只断言 toHaveBeenLastCalledWith('x'),无论哪个事件执行了三击时的复制,该断言都成立。

触发场景:若回归把按压复制限制为词模式,会在绿灯下发布;此后三击时若流式重绘落在按压与释放之间(clearSelectiondragging=false → release 提前返回、不复制),即使用户看到整行被选中也什么都不会复制。

建议仿照现有双击测试补一条 'keeps the triple-click copy when streaming clears the selection before release',或在此处断言精确的调用次数(4 次)。

— qwen3.8-max via Qwen Code /review (v0.21.9)

});

it('extends a word drag to the release cell when no move event is emitted', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2));
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
handler(makeEvent('left-release', 10)); // release over "baz" with no move
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
});

it('extends a double-click word selection backward when dragging left', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 9)); // first click on "baz"
handler(makeEvent('left-press', 9)); // double-click -> selects "baz"
handler(makeEvent('move', 1)); // drag back onto "foo"
handler(makeEvent('left-release', 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo bar baz');
});

it('extends a triple-click line selection backward when dragging up', () => {
frame = makeTwoLineFrame('hello', 'world!');
viewportRect = { x: 0, y: 0, width: 6, height: 2 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 2));
handler(makeEvent('left-release', 2, 2));
handler(makeEvent('left-press', 2, 2));
handler(makeEvent('left-release', 2, 2)); // double-click -> word "world!"
handler(makeEvent('left-press', 2, 2)); // triple-click -> line 1
handler(makeEvent('move', 2, 1)); // drag up onto line 0
handler(makeEvent('left-release', 2, 1));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 5,
ey: 1,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('hello\nworld!');
});

it('keeps covered-row trailing spaces in a multi-row line drag', () => {
frame = makeTwoLineFrame('aaa ', 'bbb');
viewportRect = { x: 0, y: 0, width: 4, height: 2 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1));
handler(makeEvent('left-press', 2, 1));
handler(makeEvent('left-release', 2, 1)); // double-click -> word "aaa"
handler(makeEvent('left-press', 2, 1)); // triple-click -> line 0
handler(makeEvent('move', 2, 2)); // drag onto line 1
handler(makeEvent('left-release', 2, 2));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 2,
ey: 1,
});
// Covered rows keep written trailing spaces (getSelectedText contract);
// only the final row ends at the line span's trimmed last content column.
expect(copyToClipboard).toHaveBeenLastCalledWith('aaa \nbbb');
});

it('falls back to the cursor cell when a word drag lands on whitespace', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2));
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
handler(makeEvent('move', 4)); // drag onto the gap after "foo"
handler(makeEvent('left-release', 4));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 3,
ey: 0,
});
expect(copyToClipboard).toHaveBeenCalledWith('foo ');
});

it('keeps the triple-click chain across drift during a held double-click', () => {
frame = makeFrame('foo bar baz');
viewportRect = { x: 0, y: 0, width: 11, height: 1 };
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1000);
const handler = mount();
handler(makeEvent('left-press', 2));
handler(makeEvent('left-release', 2));
handler(makeEvent('left-press', 2)); // double-click -> selects "foo"
handler(makeEvent('move', 4)); // drift off the word while held
handler(makeEvent('left-release', 4));
handler(makeEvent('left-press', 2)); // third click -> selects the line
handler(makeEvent('left-release', 2));
nowSpy.mockRestore();

expect(setSelection).toHaveBeenLastCalledWith({
sx: 0,
sy: 0,
ex: 10,
ey: 0,
});
expect(copyToClipboard).toHaveBeenLastCalledWith('foo bar baz');
});

it('snaps a wide-character spacer to the leading cell', () => {
frame = makeWideFrame();
const handler = mount();
Expand Down
Loading
Loading