diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index c44d76531..24999131b 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed mouse hover changing selection and recentering autocomplete and settings lists, causing clicks to target a different item. + ## [0.85.0] - 2026-09-04 ### Breaking Changes diff --git a/packages/tui/src/components/select-list.ts b/packages/tui/src/components/select-list.ts index 80b1becb4..f4f4354dd 100644 --- a/packages/tui/src/components/select-list.ts +++ b/packages/tui/src/components/select-list.ts @@ -115,23 +115,19 @@ export class SelectList implements Component { if (this.selectedIndex !== previousIndex) this.notifySelectionChange(); return { handled: true, render: this.selectedIndex !== previousIndex }; } - if (event.type !== "move" && event.button !== "left") return undefined; + // Hover must not change selection: the visible range is centered on it. + if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined; const { startIndex, endIndex } = this.getVisibleRange(); const itemIndex = startIndex + event.y; if (itemIndex < startIndex || itemIndex >= endIndex) return undefined; - if (event.type === "move" || event.type === "press") { - if (event.type === "press") this.mousePressedIndex = itemIndex; - const changed = this.selectedIndex !== itemIndex; - if (changed) { + if (event.type === "press") { + this.mousePressedIndex = itemIndex; + if (this.selectedIndex !== itemIndex) { this.selectedIndex = itemIndex; this.notifySelectionChange(); } - return { - handled: true, - focus: event.type === "press", - ...(event.type === "move" ? { render: changed } : {}), - }; + return { handled: true, focus: true }; } if (event.type === "click") { const clickedIndex = this.mousePressedIndex ?? itemIndex; diff --git a/packages/tui/src/components/settings-list.ts b/packages/tui/src/components/settings-list.ts index 8b560edda..b1b8366bf 100644 --- a/packages/tui/src/components/settings-list.ts +++ b/packages/tui/src/components/settings-list.ts @@ -198,21 +198,17 @@ export class SettingsList implements Component { this.selectedIndex = Math.max(0, Math.min(displayItems.length - 1, this.selectedIndex + delta)); return { handled: true, render: this.selectedIndex !== previousIndex }; } - if (event.type !== "move" && event.button !== "left") return undefined; + // Hover must not change selection: the visible range is centered on it. + if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined; const rowOffset = this.searchEnabled ? 2 : 0; const { startIndex, endIndex } = this.getVisibleRange(displayItems); const itemIndex = startIndex + event.y - rowOffset; if (itemIndex < startIndex || itemIndex >= endIndex) return undefined; - if (event.type === "move" || event.type === "press") { - if (event.type === "press") this.mousePressedIndex = itemIndex; - const changed = this.selectedIndex !== itemIndex; + if (event.type === "press") { + this.mousePressedIndex = itemIndex; this.selectedIndex = itemIndex; - return { - handled: true, - focus: event.type === "press", - ...(event.type === "move" ? { render: changed } : {}), - }; + return { handled: true, focus: true }; } if (event.type === "click") { this.selectedIndex = this.mousePressedIndex ?? itemIndex; diff --git a/packages/tui/test/mouse-components.test.ts b/packages/tui/test/mouse-components.test.ts index bf7e4e7c8..cd661515b 100644 --- a/packages/tui/test/mouse-components.test.ts +++ b/packages/tui/test/mouse-components.test.ts @@ -113,6 +113,76 @@ describe("mouse-aware components", () => { assert.deepStrictEqual(changes, [{ id: "third", value: "high" }]); }); + for (const row of [0, 4]) { + it(`ignores hover and clicks visible select-list row ${row} after scrolling`, () => { + const list = new SelectList( + Array.from({ length: 12 }, (_, i) => ({ value: `item-${i}`, label: `Item ${i}` })), + 5, + selectTheme, + ); + const changes: string[] = []; + let selected: string | undefined; + list.onSelectionChange = (item) => changes.push(item.value); + list.onSelect = (item) => { + selected = item.value; + }; + list.setSelectedIndex(5); + list.handleMouse({ ...mouse("wheel", 1, row), wheelDelta: 1 }); + assert.strictEqual(list.getSelectedItem()?.value, "item-6"); + assert.deepStrictEqual(changes, ["item-6"]); + const before = list.render(80); + assert.match(before[row], new RegExp(`Item ${4 + row}$`)); + + for (const y of [0, 1, 2, 3, 4, row]) { + assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y), button: "none" }), undefined); + assert.deepStrictEqual(list.render(80), before); + } + assert.strictEqual(list.getSelectedItem()?.value, "item-6"); + assert.deepStrictEqual(changes, ["item-6"]); + assert.strictEqual(selected, undefined); + + list.handleMouse(mouse("press", 1, row)); + list.render(80); + list.handleMouse(mouse("click", 1, row)); + assert.strictEqual(selected, `item-${4 + row}`); + assert.deepStrictEqual(changes, ["item-6", `item-${4 + row}`]); + }); + + it(`ignores hover and clicks visible settings row ${row} after scrolling`, () => { + const changes: Array<{ id: string; value: string }> = []; + const list = new SettingsList( + Array.from({ length: 12 }, (_, i) => ({ + id: `item-${i}`, + label: `Item ${i}`, + description: `Description ${i}`, + currentValue: "off", + values: ["off", "on"], + })), + 5, + settingsTheme, + (id, value) => changes.push({ id, value }), + () => {}, + { enableSearch: true }, + ); + list.selectItem("item-5"); + list.handleMouse({ ...mouse("wheel", 1, row + 2), wheelDelta: 1 }); + const before = list.render(80); + assert.match(before[4], /^> Item 6/); + assert.match(before[row + 2], new RegExp(`Item ${4 + row} `)); + + for (const y of [0, 1, 2, 3, 4, row]) { + assert.strictEqual(list.handleMouse({ ...mouse("move", 1, y + 2), button: "none" }), undefined); + assert.deepStrictEqual(list.render(80), before); + } + assert.deepStrictEqual(changes, []); + + list.handleMouse(mouse("press", 1, row + 2)); + list.render(80); + list.handleMouse(mouse("click", 1, row + 2)); + assert.deepStrictEqual(changes, [{ id: `item-${4 + row}`, value: "on" }]); + }); + } + it("keeps a delegating overlay focused when its nested input is clicked", async () => { const terminal = new VirtualTerminal(20, 4); const tui = new TuiAltScreen(terminal);