Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion extensions/ask-user-choice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export default function askUserChoice(pi: ExtensionAPI): void {
selectedText: (text) => theme.fg("accent", text),
description: (text) => theme.fg("muted", text),
hoverBackground: (text) => theme.bg("toolPendingBg", text),
}, keybindings);
}, keybindings, { confirmOnSecondClick: true });
let completed = false;
const finish = (result: ChoiceResult | undefined) => {
if (completed) return;
Expand Down
25 changes: 21 additions & 4 deletions lib/native-choice-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export interface NativeChoiceListTheme {
hoverBackground(text: string): string;
}

export interface NativeChoiceListOptions {
/** Require a prior completed click on the same row; keyboard confirmation is unchanged. */
confirmOnSecondClick?: boolean;
}

interface Row<T extends NativeChoiceItem> {
item: T;
text: Text;
Expand All @@ -37,16 +42,22 @@ export class NativeChoiceList<T extends NativeChoiceItem> extends Container {
private readonly theme: NativeChoiceListTheme;
private readonly keybindings: KeybindingsManager | undefined;
private selected = 0;
private pointerSelected: string | undefined;
private readonly confirmOnSecondClick: boolean;
private hovered: string | undefined;
private disabled = false;
private readonly pointerScope = new NativePointerScope();
private renderedWidth: number | undefined;

constructor(items: readonly T[], theme: NativeChoiceListTheme, keybindings?: KeybindingsManager) {
constructor(
items: readonly T[], theme: NativeChoiceListTheme, keybindings?: KeybindingsManager,
options: NativeChoiceListOptions = {},
) {
super();
this.items = items;
this.theme = theme;
this.keybindings = keybindings;
this.confirmOnSecondClick = options.confirmOnSecondClick ?? false;
for (const item of items) this.addRow(item);
this.refreshRows();
}
Expand All @@ -56,6 +67,7 @@ export class NativeChoiceList<T extends NativeChoiceItem> extends Container {
}

setSelectedIndex(index: number): void {
this.pointerSelected = undefined;
const next = Math.max(0, Math.min(this.items.length - 1, index));
if (next === this.selected) return;
this.selected = next;
Expand All @@ -76,6 +88,7 @@ export class NativeChoiceList<T extends NativeChoiceItem> extends Container {

setDisabled(disabled: boolean): void {
this.disabled = disabled;
this.pointerSelected = undefined;
this.pointerScope.setDisabled(disabled);
if (disabled) this.clearHover();
}
Expand Down Expand Up @@ -107,6 +120,7 @@ export class NativeChoiceList<T extends NativeChoiceItem> extends Container {
if (this.matches(data, "tui.select.up")) this.setSelectedIndex(this.selected - 1);
else if (this.matches(data, "tui.select.down")) this.setSelectedIndex(this.selected + 1);
else if (this.matches(data, "tui.select.confirm")) {
this.pointerSelected = undefined;
const item = this.getSelectedItem();
if (item) this.onSelect?.(item);
}
Expand Down Expand Up @@ -148,13 +162,16 @@ export class NativeChoiceList<T extends NativeChoiceItem> extends Container {
}
if (event.button !== "left") return undefined;
if (event.type === "press") {
if (this.pointerSelected !== item.id) this.pointerSelected = undefined;
const changed = this.selectItem(item.id);
return { handled: true, focus: true, render: changed };
}
if (event.type === "click") {
this.selectItem(item.id);
this.onSelect?.(item);
return { handled: true };
const confirm = !this.confirmOnSecondClick || this.pointerSelected === item.id;
const changed = this.selectItem(item.id);
this.pointerSelected = confirm ? undefined : item.id;
if (confirm) this.onSelect?.(item);
return { handled: true, render: changed };
}
return undefined;
}
Expand Down
15 changes: 15 additions & 0 deletions tests/ask-user-choice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ test("ask_user_choice retains native rendered hit testing for mouse selection",
assert.equal(press?.focus, true);
assert.equal(completed, undefined, "press focuses and selects but never answers");
component.handleMouse?.(event("click", "left", secondRow));
assert.equal(completed, undefined, "first completed click only selects");
component.handleMouse?.(event("press", "left", secondRow));
component.handleMouse?.(event("click", "left", secondRow));
component.handleInput("\r");
return completed;
Expand Down Expand Up @@ -400,6 +402,19 @@ test("ask_user_choice activates an opt-in custom response from the Other row poi
});
assert.equal(component.handleMouse?.(event("press"))?.focus, true);
component.handleMouse?.(event("click"));
assert.doesNotMatch(component.render(80).join("\n"), /Custom response/);
component.handleInput("\r");
assert.match(component.render(80).join("\n"), /Custom response/);
component.handleInput("\x1b");
assert.match(component.render(80).join("\n"), /Other…/);
component.handleMouse?.(event("press"));
component.handleMouse?.(event("release"));
component.handleMouse?.(event("click"));
assert.doesNotMatch(component.render(80).join("\n"), /Custom response/, "keyboard confirmation consumes the prior pointer selection");
assert.equal(completed, undefined);
component.handleMouse?.(event("press"));
component.handleMouse?.(event("click"));
assert.match(component.render(80).join("\n"), /Custom response/);
component.handleInput("pointer response");
component.handleInput("\r");
return completed;
Expand Down
50 changes: 50 additions & 0 deletions tests/native-choice-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,56 @@ test("root observer clears hover and disabled controls ignore every input", () =
);
});

for (const twoStep of [false, true]) {
test(`native choice completed clicks use ${twoStep ? "two-step" : "default single-click"} confirmation`, () => {
const list = new NativeChoiceList([
{ id: "first", label: "First" }, { id: "second", label: "Second" },
], theme, undefined, { confirmOnSecondClick: twoStep });
const selected: string[] = [];
list.onSelect = (item) => selected.push(item.id);
const root = createNativeFullscreenInteraction({ keyboardTarget: list, requestRender() {} });
root.addChild(new Text("Header", 0, 0));
root.addChild(list);
const lines = root.render(40);
const send = (type: TuiMouseEvent["type"], label: string) => {
const row = lines.findIndex((line) => stripTerminalSequences(line).includes(label));
assert.ok(row >= 0);
root.handleMouse(event(type, "left", row, 40, lines.length));
};
const click = (label: string) => {
send("press", label);
send("release", label);
send("click", label);
};
send("press", "First");
send("release", "First");
assert.deepEqual(selected, [], "press and release do not confirm or arm a completed click");
click("First");
assert.deepEqual(selected, twoStep ? [] : ["first"], "initial highlighted row still needs a first click");
click("Second");
assert.equal(list.getSelectedItem()?.id, "second");
assert.equal(selected.length, twoStep ? 0 : 2, "switching rows selects only in two-step mode");
click("First");
assert.equal(selected.length, twoStep ? 0 : 3, "returning to a prior row does not confirm");
root.render(40);
click("First");
assert.equal(selected.at(-1), "first", "same-row confirmation survives a render without a timer");
selected.length = 0;
click("Second");
root.handleInput("\r");
assert.equal(selected.at(-1), "second", "Enter confirms pointer selection");
selected.length = 0;
root.handleInput("\u001b[A");
root.handleInput("\u001b[B");
click("Second");
assert.deepEqual(selected, twoStep ? [] : ["second"], "keyboard navigation clears pointer confirmation");
let cancelled = false;
list.onCancel = () => { cancelled = true; };
root.handleInput("\u001b");
assert.equal(cancelled, true);
});
}

test("empty native choice lists do not activate phantom selections", () => {
const list = new NativeChoiceList([], theme);
let selected = 0;
Expand Down