Skip to content
Merged
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
16 changes: 15 additions & 1 deletion packages/core/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -816,7 +816,21 @@ export class App implements TextUIApp {
walkInstances(this.root, (instance) => {
if (position) return;
const cursor = instance.props.cursor;
if (!cursor || !instance.box) return;
/*
* Absent, not falsy.
*
* `cursor` is a column, and column 0 is a real one - it is where the
* caret sits in an empty field, and where it sits at the start of any
* field. `!cursor` treated that as "no caret here", so a text input
* published no cursor position at all until its first character was
* typed: the terminal cursor stayed hidden, and the only thing marking
* the focused control was its border colour.
*
* `true` still means "here, at offset 0", which is what the `typeof`
* below is for - so this rejects the three ways of saying nothing and
* nothing else.
*/
if (cursor === undefined || cursor === null || cursor === false || !instance.box) return;
const id = typeof instance.props.id === 'string' ? instance.props.id : instance.id;
if (id !== focused && `${instance.id}:focus` !== focused) return;

Expand Down
92 changes: 92 additions & 0 deletions packages/testing/test/text-input-caret.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';
import { h, useState } from '@textui/core';
import { TextInput } from '@textui/widgets';
import { renderApp } from '../src/index.js';

/*
* An empty field is where the caret sits at column 0, and column 0 is a column.
*
* `cursorPosition` guarded with `!cursor`, so a control publishing a caret at
* the very start of itself published nothing at all: the terminal cursor
* stayed hidden until the first character was typed. Every empty input in
* every application was affected, and what was left marking the focused
* control was its border colour - easy to miss on a form where every field has
* a border already.
*
* Read one frame at a time. `output()` accumulates every byte since the last
* `clearOutput`, so asking whether the cursor was ever shown answers about the
* whole session and not about now - which is how the first version of this
* test passed against the bug it was written for.
*/
describe('the caret in a text input', () => {
const ESC = String.fromCharCode(27);

const open = async (value: string, focused = true) => {
const t = await renderApp({
width: 30,
height: 3,
encode: true,
root: h(function Host() {
const [text, setText] = useState(value);
return h(TextInput, {
value: text, onChange: setText,
focusId: 'f', autoFocus: focused,
});
}, {}),
});
await t.settle();
if (focused) t.focus('f');
await t.settle();
return t;
};

/** Redraw from scratch and report where this frame leaves the cursor. */
const caretIn = async (t: Awaited<ReturnType<typeof open>>): Promise<{ shown: boolean; column?: number }> => {
t.clearOutput();
// A full repaint, so what comes back describes this frame rather than
// whatever the last keystroke happened to touch.
t.resize(30, 3);
await t.settle();
const out = t.output();
// Built from `ESC` rather than written as an escape, because a control
// character in a regular expression literal is a lint error.
const places = [...out.matchAll(new RegExp(`${ESC}\\[(\\d+);(\\d+)H`, 'g'))];
const last = places[places.length - 1];
return out.includes(`${ESC}[?25h`) && last
? { shown: true, column: Number(last[2]) }
: { shown: false };
};

// Inside the border and one cell of padding, so the value starts here and so
// does the caret when there is nothing in front of it.
const START = 3;

it('shows where typing will land in an empty field', async () => {
const t = await open('');
// The regression: nothing was published, so a focused empty field looked
// exactly like an unfocused one.
expect(await caretIn(t)).toEqual({ shown: true, column: START });
await t.unmount();
});

it('shows it at the start of a field that already has content', async () => {
const t = await open('abc');
t.press('home');
await t.settle();
// The same column 0, reached the other way.
expect(await caretIn(t)).toEqual({ shown: true, column: START });
await t.unmount();
});

it('puts it past the text when there is text before it', async () => {
const t = await open('abc');
expect(await caretIn(t)).toEqual({ shown: true, column: START + 3 });
await t.unmount();
});

it('shows none when nothing is focused', async () => {
const t = await open('abc', false);
expect((await caretIn(t)).shown).toBe(false);
await t.unmount();
});
});
Loading