A multi-line text editor widget with cursor navigation, scrolling, and optional syntax highlighting.
Renders inside a bordered box. An optional line-number gutter can be enabled. Syntax
highlighting is available for :elixir, :python, :javascript, and :js via the
:language option. Placeholder text is shown when the content is empty and the widget
is not focused.
Component tag
Tag :text_area, built by Drafter.App as {:text_area, opts}:
text_area(opts)There is no positional argument. The text goes through the binding layer:
passing bind: :some_key seeds the content from that app-state key and writes
every edit back to it, and :on_change is built from the same binding.
:width and :height are always taken from the rect the parent allocated.
Options
:text-String.t/0initial content. Default"". Split on"\n"into:lines. Through the element the content comes from:bindinstead.:bind- app-state key atom for two-way binding of the text. Default: none.:placeholder-String.t/0hint shown while the content is empty and the widget unfocused. Default"".:on_change-(String.t() -> term())called with the full text after every edit. Defaultnil. Through the element it is built by the framework. An exception it raises is swallowed.:max_lines-pos_integer/0cap on the number of lines. Defaultnil, unlimited.:width-pos_integer/0widget width in columns. Default40when mounting directly, and the allocated rect width through the element.:height-pos_integer/0widget height in rows including both borders. Default6when mounting directly, and the allocated rect height through the element. Onlyhandle_scroll/2reads it;render/2uses the rect.:show_line_numbers-boolean/0, draw a line-number gutter. Defaultfalse. The gutter is as wide as the digit count of the line total, at least three, plus one.:language-:elixir | :python | :javascript | :jsfor syntax highlighting. Defaultnil, no highlighting.:style-map/0of style overrides for the content area. Default%{fg: {200, 200, 200}, bg: {40, 40, 40}}when mounting directly, and%{}through the element.:read_only-boolean/0. Defaultfalse. Blocks cut, paste and character input; cursor movement and copy still work.:trap_focus-true | :arrows | false. Defaultfalse. Whentrueor:arrows,escapeblurs the editor.:tab_behavior-:focus | :indent. Default:focus, which letstabfall through to focus movement;:indentinserts:tab_sizespaces.:tab_size-pos_integer/0spaces inserted for a tab. Default2.:highlight_cursor_line-boolean/0, tint the cursor's row. Defaultfalse.:focused-boolean/0read bymount/1. Defaultfalse. Every editing binding requires it.
These are read by mount/1 only; the text_area/1 element does not forward
them:
:placeholder_style- style map for the placeholder text. Default%{fg: {100, 100, 100}, bg: {40, 40, 40}}.:focused_style- style map applied while the widget has focus. Default%{fg: {255, 255, 255}, bg: {50, 100, 200}}.:selection_style- style map for the selected range. Default%{fg: {255, 255, 255}, bg: {0, 100, 200}}.update/2does not accept it either, so it is fixed at mount.:line_number_style- style map for the gutter. Default%{fg: {100, 150, 255}, bg: {35, 35, 35}}.:max_checkpoints- undo/redo history depth. Default50.
mount/1 always starts the cursor at line 0, column 0, with no selection,
no scroll and empty undo and redo stacks; those cannot be seeded from props.
update/2 accepts every option above except :selection_style, and re-clamps
the cursor and clears the selection whenever :text actually changes. Through
the component tree update_props_from_mount/3 always passes :on_change,
:max_lines, :show_line_numbers, :language, :read_only, :trap_focus,
:tab_behavior, :tab_size and :highlight_cursor_line; :width, :height
and :placeholder only when they changed, and :text only when opts carries
:bind or :value and the text differs.
Widget value
Drafter.get_widget_value/1 returns the current text, and
Drafter.set_widget_value/2 replaces it.
Key bindings
- Arrow keys - move cursor by character or line
Shift+Arrow- extend selectionCtrl+A- select allCtrl+C- copy selection to clipboardCtrl+X- cut selection to clipboardCtrl+V- paste from clipboardCtrl+Z- undoCtrl+Y- redoCtrl+Left/Right- word navigationHome/End- move to start/end of the current linePage Up/Page Down- move cursor by viewport heightBackspace/Delete- delete character; joins lines at line boundariesEnter- insert a new line at the cursor positionEscape- blur the editor, when:trap_focusistrueor:arrowsTab- insert:tab_sizespaces, only when:tab_behavioris:indent
All of these require the widget to be focused; unfocused, every event other than
{:focus}, {:blur} and the mouse wheel returns {:noreply, state}.
Usage
text_area(placeholder: "Notes...", height: 10, show_line_numbers: true, language: :elixir)
Summary
Functions
The component tag this widget registers under.
Builds the props map for a {:text_area, opts} element.
Handles the editor's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
Scrolls the viewport by three lines per wheel step, without moving the cursor.
Builds the widget state from props.
The number of rows the element asks for: opts[:height], default 6, borders
included.
Draws the bordered editor into rect.
Callback implementation for Drafter.Widget.unmount/1.
Replaces the state fields named in props, keeping the current value for any key
that is absent.
Narrows a re-render to the props that may safely change after mount.
Types
@type selection() :: {non_neg_integer(), non_neg_integer(), non_neg_integer(), non_neg_integer()} | nil
@type t() :: %Drafter.Widget.TextArea{ cursor_col: non_neg_integer(), cursor_line: non_neg_integer(), focused: boolean(), focused_style: Drafter.Draw.Segment.style(), gutter_width: pos_integer(), h_scroll: non_neg_integer(), height: pos_integer(), highlight_cursor_line: boolean(), language: atom() | nil, line_number_style: Drafter.Draw.Segment.style(), lines: [String.t()], max_checkpoints: pos_integer(), max_lines: pos_integer() | nil, on_change: (String.t() -> term()) | nil, placeholder: String.t(), placeholder_style: Drafter.Draw.Segment.style(), read_only: boolean(), redo_stack: list(), scroll_offset: non_neg_integer(), selection: selection(), selection_style: Drafter.Draw.Segment.style(), show_line_numbers: boolean(), style: Drafter.Draw.Segment.style(), tab_behavior: :focus | :indent, tab_size: pos_integer(), text: String.t(), trap_focus: boolean() | :arrows, undo_stack: list(), width: pos_integer() }
Functions
@spec component_tag() :: :text_area
The component tag this widget registers under.
iex> Drafter.Widget.TextArea.component_tag()
:text_area
@spec from_component_opts( term(), keyword() ) :: Drafter.Widget.props()
Builds the props map for a {:text_area, opts} element.
The positional argument is ignored. :text is the bound value for that key, so bind: :key seeds it from
opts[:__app_state__] and plain value: is used otherwise, defaulting to "".
:on_change is the binding's writer. :width and :height always come from
opts[:__rect__], itself defaulting to %{width: 40, height: 6}, and a
:width or :height in opts is ignored. :style defaults to %{} here
rather than to the palette mount/1 would supply, and the four other style maps
and :max_checkpoints are not forwarded at all.
iex> props = Drafter.Widget.TextArea.from_component_opts(nil, placeholder: "Notes")
iex> {props.text, props.placeholder, props.width, props.height, props.style}
{"", "Notes", 40, 6, %{}}
iex> opts = [bind: :body, __app_state__: %{body: "hello"}, __rect__: %{width: 20, height: 4}]
iex> props = Drafter.Widget.TextArea.from_component_opts(nil, opts)
iex> {props.text, props.width, props.height, is_function(props.on_change, 1)}
{"hello", 20, 4, true}
Handles the editor's own events, replacing the dispatch use Drafter.Widget
would otherwise generate.
{:focus}, {:blur} and {:mouse, %{type: :scroll}} are handled in any state.
Every binding listed in the module doc, along with {:char, code} and
{:bracketed_paste, text}, requires :focused; anything else returns
{:noreply, state}.
Cursor moves, selection changes, undo, redo and copy return {:ok, new_state}.
Edits return whatever the editing helper reports, {:ok, state} or
{:noreply, state}, and call :on_change with the full text. A cut, paste or
character input on a :read_only editor returns {:noreply, state}.
iex> state = Drafter.Widget.TextArea.mount(%{focused: true})
iex> {:ok, typed} = Drafter.Widget.TextArea.handle_event({:char, ?a}, state)
iex> {typed.text, typed.cursor_col}
{"a", 1}
iex> state = Drafter.Widget.TextArea.mount(%{text: "hi", focused: true, read_only: true})
iex> Drafter.Widget.TextArea.handle_event({:char, ?a}, state) == {:noreply, state}
true
iex> state = Drafter.Widget.TextArea.mount(%{text: "hi"})
iex> Drafter.Widget.TextArea.handle_event({:char, ?a}, state) == {:noreply, state}
true
iex> state = Drafter.Widget.TextArea.mount(%{text: "ab\ncd", focused: true})
iex> {:ok, moved} = Drafter.Widget.TextArea.handle_event({:key, :down}, state)
iex> moved.cursor_line
1
Scrolls the viewport by three lines per wheel step, without moving the cursor.
Always returns {:ok, new_state}. Scrolling up stops at 0; scrolling down
stops at line_count - (height - 2), using the state's :height, not the rect.
Works whether or not the widget is focused.
iex> state = Drafter.Widget.TextArea.mount(%{text: Enum.join(1..20, "\n")})
iex> {:ok, down} = Drafter.Widget.TextArea.handle_scroll(:down, state)
iex> down.scroll_offset
3
iex> state = Drafter.Widget.TextArea.mount(%{text: "a\nb"})
iex> {:ok, down} = Drafter.Widget.TextArea.handle_scroll(:down, state)
iex> down.scroll_offset
0
@spec mount(Drafter.Widget.props()) :: t()
Builds the widget state from props.
Every option listed in the module doc is read here with the default stated there.
:text is split into :lines, :gutter_width is derived from
:show_line_numbers and the line count, and the cursor, scroll, selection and
history all start empty.
iex> state = Drafter.Widget.TextArea.mount(%{text: "a\nb"})
iex> {state.lines, state.cursor_line, state.cursor_col, state.gutter_width}
{["a", "b"], 0, 0, 0}
iex> state = Drafter.Widget.TextArea.mount(%{show_line_numbers: true})
iex> state.gutter_width
4
iex> state = Drafter.Widget.TextArea.mount(%{})
iex> {state.width, state.height, state.tab_behavior, state.tab_size, state.max_checkpoints}
{40, 6, :focus, 2, 50}
@spec preferred_height( term(), keyword() ) :: pos_integer()
The number of rows the element asks for: opts[:height], default 6, borders
included.
iex> Drafter.Widget.TextArea.preferred_height(nil, [])
6
iex> Drafter.Widget.TextArea.preferred_height(nil, height: 12)
12
@spec render(t() | Drafter.Widget.props(), Drafter.Widget.rect()) :: [ Drafter.Draw.Strip.t() ]
Draws the bordered editor into rect.
state may be a plain props map, in which case it is passed through mount/1
first. Returns the top border, rect.height - 2 content rows and the bottom
border, so the result is rect.height strips. The content area is
rect.width - 2 - gutter_width columns, one narrower again when a scrollbar is
needed, which happens as soon as the line count exceeds the content height.
Callback implementation for Drafter.Widget.unmount/1.
@spec update(Drafter.Widget.props(), t()) :: t()
Replaces the state fields named in props, keeping the current value for any key
that is absent.
Accepts every option except :selection_style, and never touches the cursor,
scroll offsets, selection or history directly. A :text that differs from the
current one re-splits :lines, re-clamps the cursor into the new content, clears
the selection and re-adjusts the scroll offset; identical text leaves all of that
alone. :gutter_width is recomputed on every call.
iex> state = Drafter.Widget.TextArea.mount(%{text: "one\ntwo\nthree"})
iex> state = %{state | cursor_line: 2, cursor_col: 3}
iex> updated = Drafter.Widget.TextArea.update(%{text: "a"}, state)
iex> {updated.lines, updated.cursor_line, updated.cursor_col}
{["a"], 0, 1}
iex> state = Drafter.Widget.TextArea.mount(%{text: "a"})
iex> Drafter.Widget.TextArea.update(%{show_line_numbers: true}, state).gutter_width
4
@spec update_props_from_mount(Drafter.Widget.props(), t(), keyword()) :: Drafter.Widget.props()
Narrows a re-render to the props that may safely change after mount.
Always passes :on_change, :max_lines, :show_line_numbers, :language,
:read_only, :trap_focus, :tab_behavior, :tab_size and
:highlight_cursor_line. Adds :width, :height and :placeholder only when
they differ from the mounted state, and :text only when opts carries :bind
or :value and the text differs — so an unbound editor keeps what the user
typed.
iex> props = Drafter.Widget.TextArea.from_component_opts(nil, [])
iex> state = Drafter.Widget.TextArea.mount(props)
iex> Drafter.Widget.TextArea.update_props_from_mount(props, state, []) |> Map.has_key?(:text)
false
iex> opts = [bind: :body, __app_state__: %{body: "hello"}]
iex> props = Drafter.Widget.TextArea.from_component_opts(nil, opts)
iex> state = Drafter.Widget.TextArea.mount(%{text: "old", width: 40, height: 6})
iex> Drafter.Widget.TextArea.update_props_from_mount(props, state, opts).text
"hello"