fix(tui): avoid throttling keyboard input

This commit is contained in:
Armin Ronacher 2026-08-05 20:41:32 +02:00
parent 9524d3a580
commit 29d9f087cc
3 changed files with 70 additions and 22 deletions

View file

@ -13,6 +13,7 @@
- Fixed alternate-screen Kitty images crossing vertical layout clip boundaries and overlapping sticky regions while scrolling.
- Fixed alternate-screen redraws retransmitting Kitty image data when placements move or recently offscreen images return, dropping adjacent row content when reusing placements, rendering fixed-basis scroll content twice per frame, and scanning clipped transcript rows while painting.
- Fixed fullscreen transcript navigation leaving no editor-accessible `Home`, `End`, `PageUp`, or `PageDown` variants by adding Ctrl-modified editor bindings ([#7574](https://github.com/earendil-works/pi/issues/7574)).
- Fixed keyboard input rendering latency on Windows by letting input preempt the throttled render timer.
### Added

View file

@ -337,6 +337,7 @@ export abstract class TuiBase extends Container implements TUI {
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
public onDebug?: () => void;
private renderRequested = false;
private immediateRenderScheduled = false;
private renderTimer: NodeJS.Timeout | undefined;
private lastRenderAt = 0;
private static readonly MIN_RENDER_INTERVAL_MS = 16;
@ -743,10 +744,7 @@ export abstract class TuiBase extends Container implements TUI {
stop(options: TuiStopOptions = {}): void {
this.stopped = true;
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
this.cancelRenderTimer();
if (this.terminalColorSchemeNotificationsEnabled) {
this.terminal.write("\x1b[?2031l");
}
@ -759,10 +757,7 @@ export abstract class TuiBase extends Container implements TUI {
renderNow(force = false): void {
if (force) this.resetRenderState();
this.renderRequested = false;
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
this.cancelRenderTimer();
this.lastRenderAt = performance.now();
this.doRender();
}
@ -770,19 +765,7 @@ export abstract class TuiBase extends Container implements TUI {
requestRender(force = false): void {
if (force) {
this.resetRenderState();
if (this.renderTimer) {
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
this.renderRequested = true;
process.nextTick(() => {
if (this.stopped || !this.renderRequested) {
return;
}
this.renderRequested = false;
this.lastRenderAt = performance.now();
this.doRender();
});
this.requestImmediateRender();
return;
}
if (this.renderRequested) return;
@ -790,6 +773,29 @@ export abstract class TuiBase extends Container implements TUI {
process.nextTick(() => this.scheduleRender());
}
private requestImmediateRender(): void {
this.cancelRenderTimer();
this.renderRequested = true;
if (this.immediateRenderScheduled) return;
this.immediateRenderScheduled = true;
process.nextTick(() => {
this.immediateRenderScheduled = false;
if (this.stopped || !this.renderRequested) return;
// A previously queued scheduleRender() can create a timer before this
// callback runs. User input must preempt that throttled frame.
this.cancelRenderTimer();
this.renderRequested = false;
this.lastRenderAt = performance.now();
this.doRender();
});
}
private cancelRenderTimer(): void {
if (!this.renderTimer) return;
clearTimeout(this.renderTimer);
this.renderTimer = undefined;
}
private scheduleRender(): void {
if (this.stopped || this.renderTimer || !this.renderRequested) {
return;
@ -882,7 +888,9 @@ export abstract class TuiBase extends Container implements TUI {
return;
}
this.focusedComponent.handleInput(data);
this.requestRender();
// Keyboard input is latency-sensitive. Avoid the throttled timer path,
// where even setTimeout(0) can take a full 16 ms tick on Windows.
this.requestImmediateRender();
}
}

View file

@ -24,6 +24,19 @@ class TestComponent implements Component {
invalidate(): void {}
}
class InputComponent extends TestComponent {
renderCount = 0;
override render(width: number): string[] {
this.renderCount += 1;
return super.render(width);
}
handleInput(data: string): void {
this.lines = [data];
}
}
class LoggingVirtualTerminal extends VirtualTerminal {
private writes: string[] = [];
@ -75,6 +88,32 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num
return cell.isItalic();
}
describe("TUI render scheduling", () => {
it("renders keyboard input without waiting for a throttled frame", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui: TUI = new TuiMainScreen(terminal);
const component = new InputComponent();
component.lines = ["initial"];
tui.addChild(component);
tui.setFocus(component);
tui.start();
tui.renderNow();
const renderCountBeforeInput = component.renderCount;
// Queue a normal throttled render first. Keyboard input should preempt it.
component.lines = ["pending"];
tui.requestRender();
terminal.sendInput("first");
terminal.sendInput("second");
terminal.sendInput("typed");
await new Promise<void>((resolve) => process.nextTick(resolve));
assert.strictEqual(component.renderCount, renderCountBeforeInput + 1);
assert.deepStrictEqual(component.lines, ["typed"]);
tui.stop();
});
});
describe("TUI debug logging", () => {
it("writes redraw logs to the provided directory", async () => {
const logDir = mkdtempSync(join(tmpdir(), "pi-tui-log-"));