fix(tui): keep list selection unchanged on mouse hover

This commit is contained in:
Armin Ronacher 2026-09-05 00:46:34 +02:00
parent 17de82d7be
commit 9841914c71
4 changed files with 85 additions and 19 deletions

View file

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

View file

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

View file

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

View file

@ -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);