Skip to content
Closed
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
92 changes: 92 additions & 0 deletions apps/cli/src/ui/view/editor/step-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,37 @@ const STEP_EDITOR_CONTENT_INSET = " ";
*/
const STEP_EDITOR_PROMPT_MARKER = "❯ ";

/**
* How long a keystroke that leaves the buffer empty suppresses the hint.
*
* The IME (pinyin, kana, ...) keeps the text it is composing in the terminal,
* not in the editor, so the buffer really is empty while a composition is in
* progress. The terminal paints that preedit over the cells the editor just
* wrote, and every keystroke makes the editor rewrite them with the hint. Both
* layers draw the same cell, so the hint and the composing text alternate:
* the flicker. Hiding the hint for the shortest window that covers the gaps
* between keystrokes keeps the terminal's cells to itself until the composition
* either commits into the buffer or is abandoned.
*/
const STEP_EDITOR_COMPOSING_HINT_SUPPRESS_MS = 500;

/**
* Whether a keystroke is one the editor would insert text for.
*
* A printable keystroke that leaves the buffer empty was not consumed here, so
* the text it carried is still with the terminal. Anything that is not
* printable (arrows, Ctrl chords, Escape, function keys, paste frames) types
* nothing and must not open a suppression window.
*/
function isComposableKeystroke(data: string): boolean {
if (data.length === 0) return false;
for (const char of data) {
const code = char.codePointAt(0) ?? 0;
if (code < 32 || code === 0x7f) return false;
}
return true;
}

/** A frame this narrow is less useful than pi-tui's compact native editor. */
const STEP_EDITOR_MIN_FRAME_WIDTH = 8;

Expand Down Expand Up @@ -96,6 +127,13 @@ export class StepEditor extends CustomEditor {
private keywordCount = 0;
private shimmerFrame = -1;
private shimmerTimer?: ReturnType<typeof setInterval>;
/**
* Whether an in-progress composition is keeping the hint off the screen.
* Only meaningful while the buffer is empty: with text in it the hint is
* hidden anyway. See {@link STEP_EDITOR_COMPOSING_HINT_SUPPRESS_MS}.
*/
private hintSuppressed = false;
private hintRestoreTimer?: ReturnType<typeof setTimeout>;

constructor(
tui: TUI,
Expand Down Expand Up @@ -147,12 +185,16 @@ export class StepEditor extends CustomEditor {
}

override handleInput(data: string): void {
this.noteCompositionKeystroke(data);
super.handleInput(data);
this.updateHighlights();
}

override setText(text: string): void {
super.setText(text);
// Only text disproves a composition. A programmatic clear can land
// between two keystrokes of one, so it leaves the window running.
if (text.length !== 0) this.clearHintSuppression();
this.updateHighlights();
}

Expand All @@ -162,6 +204,51 @@ export class StepEditor extends CustomEditor {
this.shimmerFrame = -1;
}

/**
* Record a keystroke that arrived while the buffer was empty and carried
* text the editor has not committed. That is the only composition evidence
* available here: no terminal reports composition start/end to the
* application, and the preedit an IME draws never reaches stdin, so the
* buffer stays empty until the composition commits. Plain typing fills the
* buffer on the first printable key, which ends the suppression by itself.
*/
private noteCompositionKeystroke(data: string): void {
if (this.getText().length !== 0) {
this.clearHintSuppression();
return;
}
if (!isComposableKeystroke(data)) return;
this.hintSuppressed = true;
// The window is the timer, not a clock comparison. Re-arming slides the
// deadline forward across the gaps between one composition's keystrokes.
this.scheduleHintRestore();
}

/**
* Whether the empty composer should keep its hint out of the terminal's
* way. A blurred editor never composes and cannot be repainted by the
* restore timer, so it always shows its hint.
*/
private isHintSuppressed(): boolean {
return this.focused && this.hintSuppressed;
}

private clearHintSuppression(): void {
if (this.hintRestoreTimer) clearTimeout(this.hintRestoreTimer);
this.hintRestoreTimer = undefined;
this.hintSuppressed = false;
}

private scheduleHintRestore(): void {
if (this.hintRestoreTimer) clearTimeout(this.hintRestoreTimer);
this.hintRestoreTimer = setTimeout(() => {
this.hintRestoreTimer = undefined;
this.hintSuppressed = false;
if (this.focused) this.tui.requestRender();
}, STEP_EDITOR_COMPOSING_HINT_SUPPRESS_MS);
this.hintRestoreTimer.unref?.();
}

private updateHighlights(): void {
const text = this.getText();
if (!this.focused) this.dispose();
Expand Down Expand Up @@ -259,6 +346,11 @@ export class StepEditor extends CustomEditor {
if ((text.length !== 0 && !bashHint) || lines.length < 2) {
return lines;
}
// An empty composer is the only row an in-progress composition can
// collide with; `!`/`!!` already holds typed text, so its hint stays.
if (!bashHint && this.isHintSuppressed()) {
return lines;
}

const contentRow = lines[1] ?? "";
const trailingSpaces = /( *)$/.exec(contentRow)?.[1]?.length ?? 0;
Expand Down
79 changes: 79 additions & 0 deletions apps/cli/test/step-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,85 @@ describe("StepEditor", () => {
for (const line of lines) expect(visibleWidth(line)).toBe(80);
});

it("hides the hint while an IME is composing into an empty composer", () => {
vi.useFakeTimers();
initTheme("step-blue");
const editor = createEditor();
editor.focused = true;
expect(editor.render(80)[1]).toContain(STEP_EDITOR_PLACEHOLDER);

// Pinyin: the terminal holds the preedit, so the buffer the editor sees
// stays empty while each keystroke repaints the region the hint was just
// written into. Re-arming on every keystroke keeps a whole composition
// covered rather than only its first stroke.
for (let index = 0; index < 6; index += 1) {
// Every keystroke of the composition reaches the editor with the
// buffer still empty: the preedit is the terminal's, so nothing the
// editor can see has been typed yet.
expect(editor.getText()).toBe("");
editor.handleInput("z");
editor.setText("");
vi.advanceTimersByTime(80);
expect(editor.render(80)[1]).not.toContain(STEP_EDITOR_PLACEHOLDER);
}

// Abandoning the composition restores the hint on a deadline, without
// waiting for unrelated input to trigger the next frame.
expect(editor.getText()).toBe("");
vi.advanceTimersByTime(500);
const restored = editor.render(80);
expect(restored[1]).toContain(STEP_EDITOR_PLACEHOLDER);
for (const line of restored) expect(visibleWidth(line)).toBe(80);
});

it("stops hiding the hint once composed text reaches the buffer", () => {
vi.useFakeTimers();
vi.setSystemTime(0);
initTheme("step-blue");
const editor = createEditor();
editor.focused = true;

editor.handleInput("z");
expect(editor.render(80)[1]).not.toContain(STEP_EDITOR_PLACEHOLDER);
expect(editor.getText()).toBe("z");
// A commit or a programmatic fill is not a composition any more, so no
// suppressed state may outlive it.
editor.setText("这是拼音字符");
editor.setText("");
expect(editor.render(80)[1]).toContain(STEP_EDITOR_PLACEHOLDER);
expect(vi.getTimerCount()).toBe(0);
});

it("keeps the hint for keystrokes that compose nothing", () => {
vi.useFakeTimers();
vi.setSystemTime(0);
initTheme("step-blue");
const editor = createEditor();
editor.focused = true;

// Arrows, Escape, and Ctrl chords type nothing, so they must not look
// like a composition starting.
for (const key of ["\x1b[D", "\x1b[A", "\x1b", "\x01"]) {
editor.handleInput(key);
vi.advanceTimersByTime(20);
expect(editor.render(80)[1]).toContain(STEP_EDITOR_PLACEHOLDER);
}
});

it("keeps the bash-mode hint while the prefix suppresses the idle hint", () => {
vi.useFakeTimers();
vi.setSystemTime(0);
initTheme("step-blue");
const editor = createEditor();
editor.focused = true;
editor.handleInput("z");
editor.setText("!");

const lines = editor.render(80);
expect(lines[1]).toContain("run a shell command (Esc to exit)");
expect(lines[1]).not.toContain(STEP_EDITOR_PLACEHOLDER);
});

it("marks the first row and aligns wrapped rows to the same content column", () => {
initTheme("step-blue");
const editor = createEditor();
Expand Down
Loading