use_text_area
use_text_area edits text that wraps onto rows:
it handles typing, deleting, moving the cursor along a row and between rows, and pasting,
and leaves drawing to the component that calls it.
It wraps the value itself, at the width the component gives it,
so the cursor moves between the rows the value is drawn on.
For a ready-made box, use text_area, which is built on this hook.
This page covers the hook and that component. Selection isn't supported yet.
Using the hook
As with use_text_input, the calling component owns the value and passes it with its setter,
and the hook owns the cursor and makes the component focusable.
It also takes the width to wrap at,
which here is the content width that use_rects reports for the box.
The box gets its width from its parent, not from its text,
since the text is wrapped to fit it.
The hook returns the rows, joined by line breaks, split around the cursor,
so the component draws them with text_wrap_none, without wrapping them again:
CURSOR = (text_color("slate", 900) | text_bg("amber", 300)).text_style
@dataclass(frozen=True, slots=True)
class Conversation:
messages: tuple[str, ...]
draft: str
@component
def composer(draft: str, edit_draft: Callable[[Callable[[str], str]], None], send: Callable[[], None]) -> Text:
rects = use_rects()
field = use_text_area(draft, edit_draft, width=rects.content.width, auto_focus=True)
def on_enter(event: InputEvent, _: Receiver) -> InputResponse:
match event:
case KeyPressed(key=Key.Enter):
send()
return ()
case _:
return None
return Text(
content=(
Chunk(content=field.before_cursor),
Chunk(content=field.at_cursor, style=CURSOR),
Chunk(content=field.after_cursor),
),
style=border | border_lightrounded | border_color("amber", 400) | pad_x(1) | text_wrap_none,
on_input=first(field.on_input, field.on_press, on_enter),
)
@component
def chat() -> Div:
conversation, set_conversation = use_state(Conversation(messages=(), draft=""))
def edit_draft(edit: Callable[[str], str]) -> None:
set_conversation(lambda c: Conversation(messages=c.messages, draft=edit(c.draft)))
def send() -> None:
set_conversation(lambda c: Conversation(messages=(*c.messages, c.draft), draft=""))
return Div(
style=col | size(34, 9) | gap(1),
children=(
Div(
style=fill(1) | col | justify_children_end | gap(1) | overflow_y_scroll | stick_to_end_y,
on_input=scroll_on_wheel(1),
children=tuple(
Text(content=message, style=text_wrap_pretty | text_color("slate", 400))
for message in conversation.messages
),
),
composer(conversation.draft, edit_draft, send),
),
)
Enter isn't an edit, so it bubbles, and here the composer takes it to send the draft. Alt+Enter and Ctrl+J insert a line break:
Enter sends it, keeping its line break:
The conversation holds the messages and the draft together,
so send reads the latest draft from the conversation it updates,
rather than the draft this render saw, which misses keys typed since.
The messages are in a scroll container that fills the space above the composer,
with stick_to_end_y, so it stays scrolled to the newest message,
and justify_children_end, so the first few messages sit just above the composer, as chats show them.
Older messages scroll up out of view, and the wheel scrolls back to them:
Line breaks
Pass enter_inserts_newline=True for Enter to insert a line break, as in a text editor,
rather than bubble, as for a form or a chat that submits on Enter.
Alt+Enter and Ctrl+J insert one either way.
Shift+Enter can't stand in for them: most terminals send Shift+Enter as plain Enter,
so terminal programs that submit on Enter, chat prompts among them, take Alt+Enter for a new line.
Alt+Enter arrives from terminals that send Alt as an escape,
as most do by default,
and Ctrl+J from all of them.
A paste keeps its line breaks, however they're written,
and expands each tab to spaces up to the next tab stop, every tab_size columns (4 by default),
counting the line's text before the cursor,
so pasted code keeps its indentation and the alignment of its columns:
Tab stops are counted in characters, as highlight counts them,
so a wide character before a tab, such as 日, pushes what follows one column further than a tab stop.
A browser's <textarea> keeps the tab, and CSS's tab-size sets how wide it's drawn;
counterweight's text is drawn without tab stops, so the paste expands them instead.
How the text wraps
A row breaks after the last whitespace that fits, or wherever it fills when a word is wider than a row.
Rows are filled one at a time, as text editors fill them,
rather than evened out as text_wrap_pretty evens out a paragraph,
so an edit rewraps its line only from the row above the cursor onward.
The whitespace at a break stays at the end of its row, taking its cells,
and a cell is kept after the end of each line,
so wherever the cursor is, there's a cell to draw it in:
a line that exactly fills its rows puts the cursor at its end on a row of its own.
Keeping the cursor in view
A text area in a scroll container, such as one with a max_height, has to scroll as the cursor moves past its edge.
The hook returns a cursor_row ElementHandle
for the element the component draws the cursor's row in,
and its handler returns ScrollIntoView
for that handle, with block="nearest", for each key press and paste it handles.
That applies to the layout of the render the edit causes,
so the container scrolls in the same frame the cursor moves, and only as far as it takes,
as a browser's <textarea> scrolls to its caret.
The cursor starts at the end of the value, so give a scroll container that holds the rows
stick_to_end_y, which starts it scrolled to its end,
so the cursor is in view from the start, as a browser keeps a focused <textarea>'s caret in view,
rather than showing the top of a value that's already taller than the container.
To use it, draw the cursor's row as an element of its own and give it the handle.
before_cursor and after_cursor separate rows with line breaks,
so the rows above the cursor's are everything in before_cursor up to its last line break,
and the rows below are everything in after_cursor after its first.
The styled text area draws those as a Text each, around the cursor's row.
A component that draws all the rows in one element can leave out the handle,
and ScrollIntoView then scrolls nothing.
Moving the cursor with a press
A click in a browser's <textarea> focuses it and puts the caret where it lands.
The hook can't know where the component draws the rows, so it returns a second handler, on_press,
for the element the rows are drawn in, from the top-left corner of its content box.
It reads a press of the left button as a cell of the rows, moved by that element's scroll if it's a scroll container,
puts the cursor on the character drawn across that cell, or at the end of the row past its text,
taking a press above the first row or below the last as one on that row,
and focuses the component.
The composer above draws its rows in the element it returns,
so it puts both handlers there: first(field.on_input, field.on_press, on_enter).
Keys
The keys are use_text_input's, with these acting on rows:
| Key | Edit |
|---|---|
| Up, Ctrl+P | Move to the row above, at the same column, or to the start from the first row |
| Down, Ctrl+N | Move to the row below, at the same column, or to the end from the last row |
| Home | Move to the start of the row |
| Ctrl+A | Move to the start of the row, or to the start from the start of a row |
| End | Move to the end of the row |
| Ctrl+E | Move to the end of the row, or to the end from the end of a row |
| Ctrl+U | Delete to the start of the row |
| Ctrl+K | Delete to the end of the row |
| PageUp | Move a page of rows up, at the same column, or to the start from the first row |
| PageDown | Move a page of rows down, at the same column, or to the end from the last row |
| Ctrl+J, Alt+Enter | Insert a line break |
| Enter | Insert a line break, with enter_inserts_newline=True |
Up and Down keep aiming for the column they started from, so the cursor comes back to it after passing through a shorter row. The end of a row that wraps is the start of the next, so End on one moves to its last character, usually the whitespace it wrapped at. Left, Right, Backspace and Delete step over a line break as one character, and the word keys treat it as whitespace. Ctrl+T swaps characters within a line, so it does nothing at the start of one.
PageUp and PageDown move the cursor, as they move a browser's caret in a <textarea>,
rather than bubbling to a scroll container around the text area,
which would scroll the text area itself out of view.
When the element the handler is on is a scroll container,
a page is as many rows as scroll_on_keys pages it by,
87.5% of the height of its view,
and the handler scrolls it by a page too, so the cursor stays where it was in the view.
When it isn't, its content box holds every row, so a page is 87.5% of that.
Validating and transforming
The setter works as it does for use_text_input:
it's called with a function from the latest value to the edited one,
and wrapping it validates or transforms each edit.
The cursor stays where it is when the setter rejects an edit,
and moves to the end of the value when the value changes other than by an edit.
A styled text area
text_area draws a bordered box around the rows,
wrapped at the width of its box,
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, other than width, a placeholder to show in an empty box,
a style merged over its own,
and styles for its colors.
It's as wide as its parent or its style makes it, and grows to fit its rows,
up to a max_height in its style, even in a column too short for everything in it:
@component
def notes() -> Div:
text, set_text = use_state("")
return Div(
style=col | width(30),
children=(
text_area(
text,
set_text,
placeholder="Notes",
style=max_height(6),
enter_inserts_newline=True,
auto_focus=True,
),
),
)
Past its max_height it scrolls, keeping the cursor in view,
and the wheel scrolls it too.
It starts scrolled to its end, where the cursor starts:
API
counterweight.headless.use_text_area
use_text_area(
value: str,
set_value: Callable[[Callable[[str], str]], None],
width: int,
enter_inserts_newline: bool = False,
tab_size: int = 4,
auto_focus: bool = False,
handle: FocusHandle | None = None,
is_focusable: bool = True,
in_focus_order: bool = True,
) -> TextArea
Edits text that wraps onto rows, leaving how it's drawn to the calling component. The calling component owns the value, and the hook owns the cursor, makes the component focusable, and wraps the value, so the cursor can move between the rows it's drawn on.
| 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,
as for |
width
|
How many cells wide each row is, such as the content width
TYPE:
|
enter_inserts_newline
|
Whether Enter inserts a line break, as in a text editor, rather than bubbling to the element's ancestors, as for a form that submits on Enter. Alt+Enter and Ctrl+J insert one either way.
TYPE:
|
tab_size
|
How many columns apart the tab stops a paste expands each tab to are,
as for
TYPE:
|
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 |
|---|---|
TextArea
|
The value wrapped into rows and split around the cursor, for the calling component to draw without wrapping it again, |
TextArea
|
the handler that edits it, for the calling component to put on the element it returns, |
TextArea
|
and the handler that moves the cursor to a press, for the element it draws the rows in. |
TextArea
|
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.TextArea
dataclass
TextArea(
before_cursor: str,
at_cursor: str,
after_cursor: str,
cursor: int,
cursor_row: ElementHandle,
focus: Focus,
on_input: InputHandler,
on_press: InputHandler,
)
What use_text_area returns to the calling component.
before_cursor
instance-attribute
before_cursor: str
The value's rows before the cursor, each followed by a line break, then its row'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 a line or of the value, so there's always a cell to draw the cursor in.
after_cursor
instance-attribute
after_cursor: str
The text of the cursor's row after at_cursor, then each row after it, following a line break.
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_row
instance-attribute
cursor_row: ElementHandle
The element handle for the calling component to give the element it draws the cursor's row in,
which on_input scrolls into view as the cursor moves,
so a text area in a scroll container keeps the cursor in view.
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_row for each event it edits with,
after a ScrollBy of a page for PageUp and PageDown
when that element is a scroll container,
and None for every event while that element isn't focused,
and for the key presses it doesn't edit with, such as Tab, Escape, and Enter unless it inserts a line break,
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 <textarea> does,
for the calling component to put on the element it draws the rows in,
from the top-left corner 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 its row past the row's text, or to the first row or the last from above or below them,
and returns FocusOn for the component
with ScrollIntoView for cursor_row.
It returns None for every other event, and for every event while the component isn't focusable.
counterweight.widgets.text_area
text_area(
value: str,
set_value: Callable[[Callable[[str], str]], None],
placeholder: str = "",
style: Style = default,
styles: TextFieldStyles = TextFieldStyles(),
enter_inserts_newline: bool = False,
tab_size: int = 4,
auto_focus: bool = False,
handle: FocusHandle | None = None,
is_focusable: bool = True,
in_focus_order: bool = True,
) -> Div
A bordered box for editing text that wraps onto rows, built on use_text_area,
which takes value, set_value, enter_inserts_newline, tab_size 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.
It's as wide as its parent or its style makes it, and grows to fit its rows,
up to a max_height, past which it scrolls, keeping the cursor in view, and the wheel scrolls it.
It 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:
|