use_text_input
use_text_input edits one line of text: it handles typing, deleting, moving the cursor, and pasting,
and leaves drawing to the component that calls it, as Headless UI
and React Aria do for the web.
The component styles the text and the cursor however it likes,
in whatever layout it's part of.
For a ready-made box, use text_input, which is built on this hook.
This page covers the hook and that component.
For text that wraps onto rows, use use_text_area.
Selection isn't supported yet.
Using the hook
The calling component owns the value, as with React's controlled <input>,
and passes it to the hook with its setter.
The hook owns the cursor, makes the component focusable,
and returns the value split around the cursor, with the handler that edits it:
FRUITS = ("apple", "banana", "cherry", "grape", "lemon", "mango", "orange", "papaya")
CURSOR = (text_color("slate", 900) | text_bg("amber", 300)).text_style
@component
def search_box(query: str, set_query: Setter[str]) -> Text:
field = use_text_input(query, set_query, auto_focus=True)
cursor = CURSOR if field.focus.is_focused else CellStyle()
return Text(
content=(
Chunk(content="search: ", style=text_color("amber", 400).text_style),
Chunk(content=field.before_cursor),
Chunk(content=field.at_cursor, style=cursor),
Chunk(content=field.after_cursor),
),
on_input=first(field.on_input, focus_receiver_on_press),
)
@component
def search() -> Div:
query, set_query = use_state("")
results, _ = use_state(ElementHandle)
matches = [fruit for fruit in FRUITS if query in fruit]
def scroll_results(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key=Key.Up):
return ScrollBy(results, y=-1)
case KeyPressed(key=Key.Down):
return ScrollBy(results, y=1)
case _:
return None
return Div(
style=col | gap(1),
on_input=scroll_results,
children=(
search_box(query, set_query),
Div(
style=col | max_height(5) | overflow_y_scroll,
handle=results,
on_input=scroll_on_wheel(1),
children=(Text(content="\n".join(matches) or "no matches", style=text_color("slate", 400)),),
),
),
)
before_cursor, at_cursor and after_cursor together are the value,
except that at_cursor is a space when the cursor is at the end of the value,
so there's always a cell to draw the cursor in.
The hook also returns cursor, the cursor's offset into the value in code points,
so value[:cursor] is the text before it.
Here the cursor is a highlighted cell, drawn only while the search box is focused:
Typing edits the value, and the list below it re-renders from the new value:
Left, three times, moves the cursor back between the m and the n of mngo,
and an a typed there lands at the cursor:
Put field.on_input on the element the component returns, which is the one that's focused.
The handler ignores everything while that element isn't focused,
and returns None for the keys it doesn't edit with, such as Enter, Tab, Up and Down,
so they bubble on to a form that submits on Enter
or moves focus on Tab.
Here the search takes Up and Down as they bubble past the box,
and scrolls the list of matches,
which is at most five rows tall:
Compose it with focus_receiver_on_press
for the component to take focus when it's pressed,
and with handlers of your own:
first(field.on_input, on_enter) handles a key the field passes on,
and first(on_ctrl_k, field.on_input) takes over a key the field would handle.
Moving the cursor with a press
A click in a browser's <input> focuses it and puts the caret where it lands.
The hook can't know where the component draws the value, so it returns a second handler, on_press,
for the element the value is drawn in, which may be inside the one the component returns.
It reads a press of the left button as a cell of the value,
counted from the left edge of that element's content box and moved by its scroll if it's a scroll container,
puts the cursor on the character drawn across that cell, or at the end of the value past it,
and focuses the component.
In the command line below,
it's on the row that scrolls the value rather than on the one that holds the $ prompt too.
Scrolling a long value
A browser's <input> scrolls a value wider than the box sideways, keeping the caret in view.
The hook does its part of that: it returns a cursor_cell element handle
(an ElementHandle, which names an element, unlike the focus handle the hook's handle parameter takes),
and its handler returns ScrollIntoView
for that handle, with block="nearest" and inline="nearest", for each key press and paste it handles.
The control applies to the layout of the render the edit causes,
so the value scrolls in the same frame the cursor moves, and only as far as it takes.
To use it, draw at_cursor as an element of its own, give it the handle,
and put the three parts in a row that scrolls sideways,
with text_wrap_none so none of them wraps.
Draw a cell after the value while the cursor is off its end,
so the value is as wide wherever the cursor is,
and doesn't move under the pointer when a press takes the cursor off the end:
@component
def command_line() -> Div:
command, set_command = use_state("")
field = use_text_input(command, set_command, auto_focus=True)
cursor = CURSOR if field.focus.is_focused else CellStyle()
return Div(
style=row | width(24),
on_input=first(field.on_input, focus_receiver_on_press),
children=(
Text(content="$ ", style=text_color("amber", 400)),
Div(
style=row | overflow_x_scroll | stick_to_end_x,
on_input=field.on_press,
children=(
Text(content=field.before_cursor, style=text_wrap_none),
Text(
content=field.at_cursor,
style=text_wrap_none | Style(text_style=cursor),
handle=field.cursor_cell,
),
Text(
content=field.after_cursor + (" " if field.cursor < len(command) else ""),
style=text_wrap_none,
),
),
),
),
)
Home moves the cursor to the start, and the row scrolls back to it:
The cursor starts at the end of the value, so the row has stick_to_end_x,
which starts it scrolled to its end when the value is already wider than the box.
The same ScrollIntoView scrolls every scroll container above the cell,
so a field in a scrolling form comes back into view as soon as you type in it.
A component that draws the value in one element can leave out the handle,
and ScrollIntoView then scrolls nothing.
Keys
The handler follows Readline's default bindings, which most terminal programs share:
| Key | Edit |
|---|---|
| Text | Insert it at the cursor |
| Left, Ctrl+B | Move one character left |
| Right, Ctrl+F | Move one character right |
| Home, Ctrl+A, Ctrl+Home | Move to the start |
| End, Ctrl+E, Ctrl+End | Move to the end |
| Ctrl+Left, Alt+Left, Alt+B | Move to the start of this word or the previous one |
| Ctrl+Right, Alt+Right, Alt+F | Move to the end of this word or the next one |
| Backspace | Delete the character before the cursor |
| Delete, Ctrl+D | Delete the character at the cursor |
| Ctrl+W | Delete back to the start of the whitespace-separated word |
| Alt+Backspace | Delete back to the start of this word or the previous one |
| Ctrl+Delete, Alt+Delete, Alt+D | Delete to the end of this word or the next one |
| Ctrl+U | Delete to the start |
| Ctrl+K | Delete to the end |
| Ctrl+T | Swap the characters before and at the cursor, or the last two at the end |
A character is a grapheme cluster, so the cursor steps over an emoji or an accented letter whole.
A word is a run of letters and digits,
so Alt+Backspace stops at punctuation, as Readline's backward-kill-word does,
where Ctrl+W, its unix-word-rubout, deletes back to whitespace: from the end of cd ../src,
Alt+Backspace leaves cd ../ and Ctrl+W leaves cd.
The Alt keys arrive from terminals that send Alt as an escape,
as most do by default.
Alt+Enter isn't an edit, so it bubbles as Enter does, as Key.AltEnter.
A paste inserts its text in one edit, with each line break and tab turned into a space and any other control characters dropped, on terminals that support bracketed paste. On others, the pasted text arrives as typed keys, and its line breaks arrive as Enter, or as Ctrl+J from a terminal that sends them as line feeds.
Validating and transforming
The setter that use_text_input takes is called with a function from the latest value to the edited one,
rather than with the edited value.
Several key presses can arrive between renders, as when typing fast,
and each edit has to apply to the result of the one before,
which the value the component rendered with doesn't have yet.
A use_state setter takes such a function and applies it right away.
To validate or transform an edit, wrap the setter in one that applies the edit itself and decides what to keep. This one keeps only digits:
def digits_only(set_value: Setter[str]) -> Callable[[Callable[[str], str]], None]:
def set_digits(edit: Callable[[str], str]) -> None:
set_value(lambda value: edited if (edited := edit(value)).isdigit() or not edited else value)
return set_digits
@component
def sign_up() -> Div:
name, set_name = use_state("")
age, set_age = use_state("")
return Div(
style=col | width(30),
on_input=tab_focus,
children=(
text_input(name, set_name, placeholder="Name", auto_focus=True),
text_input(age, digits_only(set_age), placeholder="Age"),
),
)
Typing 3x6 into the age field gives 36, since the x would have made the value something other than digits:
The cursor stays where it is when the setter rejects an edit, and when the value changes other than by an edit, such as when a form clears it after submitting or fills in an earlier entry, the cursor moves to the end of the new value.
A styled text input
text_input draws a bordered box around the value,
with the cursor highlighted while it's focused,
and takes focus when it's pressed, with the cursor where the press lands.
It takes the hook's parameters, a placeholder to show in an empty box,
a style merged over its own, such as a width,
and styles for its colors.
The sign-up form above uses it, and shows its placeholders when it starts:
A value wider than the box scrolls inside its border, keeping the cursor in view:
Like an <input>, it doesn't grow to fit its value:
beside other elements in a row, it shrinks to fit, and scrolls.
API
counterweight.headless.use_text_input
use_text_input(
value: str,
set_value: Callable[[Callable[[str], str]], None],
auto_focus: bool = False,
handle: FocusHandle | None = None,
is_focusable: bool = True,
in_focus_order: bool = True,
) -> TextInput
Edits one line of text, leaving how it's drawn to the calling component. The calling component owns the value, and the hook owns the cursor and makes the component focusable.
| PARAMETER | DESCRIPTION |
|---|---|
value
|
The text being edited.
TYPE:
|
set_value
|
Called with a function from the latest value to the edited one for each edit,
which it MUST call before returning, as a |
auto_focus
|
Passed to
TYPE:
|
handle
|
The focus handle passed to
TYPE:
|
is_focusable
|
Passed to
TYPE:
|
in_focus_order
|
Passed to
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
TextInput
|
The value split around the cursor, for the calling component to draw, |
TextInput
|
the handler that edits it, for the calling component to put on the element it returns, |
TextInput
|
and the handler that moves the cursor to a press, for the element it draws the value in. |
TextInput
|
The cursor starts at the end of the value, and moves to the end when the value changes other than by an edit. |
counterweight.headless.TextInput
dataclass
TextInput(
before_cursor: str,
at_cursor: str,
after_cursor: str,
cursor: int,
cursor_cell: ElementHandle,
focus: Focus,
on_input: InputHandler,
on_press: InputHandler,
)
What use_text_input returns to the calling component.
at_cursor
instance-attribute
at_cursor: str
The grapheme cluster at the cursor, or a space when the cursor is at the end of the value, so there's always a cell to draw the cursor in.
cursor
instance-attribute
cursor: int
How many of the value's code points come before the cursor, so value[:cursor] is the text before it.
cursor_cell
instance-attribute
cursor_cell: ElementHandle
The element handle for the calling component to give the element it draws at_cursor in,
which on_input scrolls into view as the cursor moves,
so a value wider than its box, drawn in a scroll container, keeps the cursor in view,
as a browser's <input> does.
focus
instance-attribute
focus: Focus
Whether the calling component is focused, and its focus handle,
as use_focus returns them.
on_input
instance-attribute
on_input: InputHandler
The handler that edits the value, for the calling component to put on the element it returns.
It returns ScrollIntoView for cursor_cell for each event it edits with,
and None for every event while that element isn't focused,
and for the key presses it doesn't edit with, such as Enter, Tab, Up and Down,
so they bubble to the element's ancestors.
on_press
instance-attribute
on_press: InputHandler
The handler that moves the cursor to where a press lands, as a click in a browser's <input> does,
for the calling component to put on the element it draws the value in,
from the left edge of that element's content box, scrolled by that element's scroll if it's a scroll container.
For a press of the left button, it moves the cursor to the cluster drawn across the cell pressed,
or to the end of the value past it, and returns FocusOn for the component
with ScrollIntoView for cursor_cell.
It returns None for every other event, and for every event while the component isn't focusable.
counterweight.widgets.text_input
text_input(
value: str,
set_value: Callable[[Callable[[str], str]], None],
placeholder: str = "",
style: Style = default,
styles: TextFieldStyles = TextFieldStyles(),
auto_focus: bool = False,
handle: FocusHandle | None = None,
is_focusable: bool = True,
in_focus_order: bool = True,
) -> Div
A bordered box for editing one line of text, built on use_text_input,
which takes value, set_value and the focus parameters, handle being a focus handle.
It draws the cursor as a highlighted cell while it's focused, and a press on it focuses it,
and on its text moves the cursor there.
A value wider than the box scrolls sideways inside it, keeping the cursor in view,
as a browser's <input> does, and starts scrolled to its end, where the cursor starts.
| PARAMETER | DESCRIPTION |
|---|---|
placeholder
|
Shown in place of an empty value.
TYPE:
|
style
|
Merged over the box's style from
TYPE:
|
styles
|
How the box, the cursor and the placeholder are drawn.
TYPE:
|