Skip to content

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:

An empty search box, focused, above every fruit

search:                       

apple                         
banana                        
cherry                        
grape                         
lemon                         

Typing edits the value, and the list below it re-renders from the new value:

The search box holding "an", above the fruits that contain it

search: an                    

banana                        
mango                         
orange                        

Left, three times, moves the cursor back between the m and the n of mngo, and an a typed there lands at the cursor:

The search box holding "mango", with the cursor on the "n"

search: mango                 

mango                         

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:

The list of fruits scrolled down two rows

search:                       

cherry                        
grape                         
lemon                         
mango                         
orange                        

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,
                    ),
                ),
            ),
        ),
    )

A command line scrolled to the end of a long command

$  "Scroll long values" 

Home moves the cursor to the start, and the row scrolls back to it:

The command line scrolled back to its start

$ git commit -m "Scroll 

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 sign-up form with a name pasted in and 36 typed as the age

╭────────────────────────────╮
│ Ada Lovelace               │
╰────────────────────────────╯
╭────────────────────────────╮
│ 36                         │
╰────────────────────────────╯

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:

The sign-up form with both fields empty, showing their placeholders

╭────────────────────────────╮
│ Name                       │
╰────────────────────────────╯
╭────────────────────────────╮
│ Age                        │
╰────────────────────────────╯

A value wider than the box scrolls inside its border, keeping the cursor in view:

The name field scrolled to the end of a long name

╭────────────────────────────╮
│ ing, Countess of Lovelace  │
╰────────────────────────────╯
╭────────────────────────────╮
│ Age                        │
╰────────────────────────────╯

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: str

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 use_state setter does. Several key presses can arrive between renders, as when typing fast, so an edit applies to the latest value rather than to value, which is the one this render saw. Validate or transform an edit by wrapping the setter: lambda edit: set_value(lambda v: new if (new := edit(v)).isdigit() or not new else v) keeps only digits, and lets the value be emptied. Each edit also calls it before and after with a function that returns the value it's given, to read the latest value, and then the one the edit settled at: the cursor moves with an edit unless the setter left the value as it was before it.

TYPE: Callable[[Callable[[str], str]], None]

auto_focus

Passed to use_focus.

TYPE: bool DEFAULT: False

handle

The focus handle passed to use_focus.

TYPE: FocusHandle | None DEFAULT: None

is_focusable

Passed to use_focus.

TYPE: bool DEFAULT: True

in_focus_order

Passed to use_focus.

TYPE: bool DEFAULT: True

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.

before_cursor instance-attribute

before_cursor: str

The value's text before the cursor.

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.

after_cursor instance-attribute

after_cursor: str

The value's text after at_cursor.

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: str DEFAULT: ''

style

Merged over the box's style from styles, as for a size (width(20)).

TYPE: Style DEFAULT: default

styles

How the box, the cursor and the placeholder are drawn.

TYPE: TextFieldStyles DEFAULT: TextFieldStyles()