Packages

Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.

Current section

Files

Jump to
raxol lib raxol ui components input multi_line_input.ex
Raw

lib/raxol/ui/components/input/multi_line_input.ex

defmodule Raxol.UI.Components.Input.MultiLineInput do
@moduledoc """
A multi-line input component for text editing, supporting line wrapping, scrolling, selection, and accessibility.
**BREAKING:** All styling is now theme-driven. The `style` field and `@default_style` are removed. Use the theme system for all appearance customization.
Harmonized with modern Raxol component standards (style/theme merging, lifecycle hooks, accessibility props).
"""
alias Raxol.UI.Components.Base.Component
require Raxol.Core.Runtime.Log
require Raxol.View.Elements
@behaviour Component
@default_width 40
@default_height 10
@typedoc "State for the MultiLineInput component. See @type t for field details."
@type t :: %__MODULE__{
id: String.t() | nil,
value: String.t(),
placeholder: String.t(),
width: integer(),
height: integer(),
theme: map(),
wrap: :none | :char | :word,
cursor_pos: {integer(), integer()},
scroll_offset: {integer(), integer()},
selection_start: {integer(), integer()} | nil,
selection_end: {integer(), integer()} | nil,
history: any(),
shift_held: boolean(),
focused: boolean(),
on_change: (String.t() -> any()) | nil,
on_submit: (-> any()) | nil,
aria_label: String.t() | nil,
tooltip: String.t() | nil,
lines: [String.t()]
}
defstruct id: nil,
value: "",
placeholder: "",
width: @default_width,
height: @default_height,
theme: %{},
wrap: :word,
cursor_pos: {0, 0},
scroll_offset: {0, 0},
selection_start: nil,
selection_end: nil,
history: nil,
shift_held: false,
focused: false,
on_change: nil,
on_submit: nil,
aria_label: nil,
tooltip: nil,
lines: [""]
@spec init(map()) :: __MODULE__.t()
@impl true
@doc """
Initializes the MultiLineInput state, harmonizing style/theme/extra props and splitting lines for editing.
"""
def init(props) do
id = props[:id] || Raxol.Core.ID.generate()
value = props[:value] || ""
width = props[:width] || @default_width
height = props[:height] || @default_height
wrap = props[:wrap] || :word
placeholder = props[:placeholder] || ""
theme = props[:theme] || %{}
aria_label = props[:aria_label]
tooltip = props[:tooltip]
on_change = props[:on_change]
on_submit = props[:on_submit]
# Use the canonical helper for line splitting
lines =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.split_into_lines(
value,
width,
wrap
)
%__MODULE__{
id: id,
value: value,
placeholder: placeholder,
width: width,
height: height,
theme: theme,
wrap: wrap,
cursor_pos: {0, 0},
scroll_offset: {0, 0},
selection_start: nil,
selection_end: nil,
history: nil,
shift_held: false,
focused: false,
on_change: on_change,
on_submit: on_submit,
aria_label: aria_label,
tooltip: tooltip,
lines: lines
}
end
@doc """
Mounts the MultiLineInput component. Performs any setup needed after initialization.
"""
@spec mount(__MODULE__.t()) :: __MODULE__.t()
@impl true
def mount(state), do: state
@doc """
Unmounts the MultiLineInput component, performing any necessary cleanup.
"""
@spec unmount(__MODULE__.t()) :: __MODULE__.t()
@impl true
def unmount(state), do: state
@impl true
def update({:update_props, new_props}, state) do
new_state = Map.merge(state, new_props)
new_state = ensure_cursor_visible(new_state)
{:noreply, new_state, nil}
end
def update({:input, char_codepoint}, state)
when is_integer(char_codepoint) or is_binary(char_codepoint) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.insert_char(
state,
char_codepoint
)
trigger_on_change({:noreply, ensure_cursor_visible(new_state), nil}, state)
end
def update({:backspace}, state) do
new_state =
if elem(
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
),
0
) != nil do
{s, _deleted} =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.delete_selection(
state
)
s
else
Raxol.UI.Components.Input.MultiLineInput.TextHelper.handle_backspace_no_selection(
state
)
end
trigger_on_change({:noreply, ensure_cursor_visible(new_state), nil}, state)
end
def update({:delete}, state) do
new_state =
if elem(
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
),
0
) != nil do
{s, _deleted} =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.delete_selection(
state
)
s
else
Raxol.UI.Components.Input.MultiLineInput.TextHelper.handle_delete_no_selection(
state
)
end
trigger_on_change({:noreply, ensure_cursor_visible(new_state), nil}, state)
end
def update({:enter}, state) do
{state_after_delete, _} =
if elem(
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
),
0
) != nil do
Raxol.UI.Components.Input.MultiLineInput.TextHelper.delete_selection(
state
)
else
{state, ""}
end
new_state =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.insert_char(
state_after_delete,
10
)
trigger_on_change({:noreply, ensure_cursor_visible(new_state), nil}, state)
end
def update({:move_cursor, direction}, state)
when direction in [:left, :right, :up, :down] do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor(
state,
direction
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_line_start}, state) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_line_start(
state
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_line_end}, state) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_line_end(
state
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_page, direction}, state)
when direction in [:up, :down] do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_page(
state,
direction
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_doc_start}, state) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_doc_start(
state
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_doc_end}, state) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_doc_end(
state
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_to, {row, col}}, state) do
new_state =
%{state | cursor_pos: {row, col}}
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:select_and_move, direction}, state) do
handle_selection_move(state, direction)
end
def update({:select_all}, state) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.select_all(
state
)
{:noreply, new_state, nil}
end
def update({:copy}, state) do
if elem(
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
),
0
) != nil do
Raxol.UI.Components.Input.MultiLineInput.ClipboardHelper.copy_selection(
state
)
{:noreply, state, nil}
else
{:noreply, state, nil}
end
end
def update({:cut}, state) do
if elem(
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
),
0
) != nil do
{new_state, cmd} =
Raxol.UI.Components.Input.MultiLineInput.ClipboardHelper.cut_selection(
state
)
trigger_on_change(
{:noreply, ensure_cursor_visible(new_state), cmd},
state
)
else
{:noreply, state, nil}
end
end
def update({:paste}, state) do
{state, commands} =
Raxol.UI.Components.Input.MultiLineInput.ClipboardHelper.paste(state)
{state, commands}
end
def update({:clipboard_content, content}, state) when is_binary(content) do
{start_pos, end_pos} =
if state.selection_start && state.selection_end do
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
)
else
{state.cursor_pos, state.cursor_pos}
end
{new_value, _replaced} =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.replace_text_range(
state.value,
start_pos,
end_pos,
content
)
{start_row, start_col} = start_pos
{new_row, new_col} =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.calculate_new_position(
start_row,
start_col,
content
)
new_state = %{
state
| value: new_value,
cursor_pos: {new_row, new_col},
selection_start: nil,
selection_end: nil
}
if state.on_change, do: state.on_change.(new_value)
new_state
end
def update({:clipboard_content, _}, state) do
{:noreply, state, nil}
end
def update(:focus, state) do
{:noreply, %{state | focused: true}, nil}
end
def update(:blur, state) do
{:noreply,
%{state | focused: false, selection_start: nil, selection_end: nil}, nil}
end
def update({:set_shift_held, held}, state) do
{:noreply, %{state | shift_held: held}, nil}
end
def update({:delete_selection, direction}, state)
when direction in [:backward, :forward] do
if elem(
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
),
0
) != nil do
Raxol.UI.Components.Input.MultiLineInput.TextHelper.delete_selection(
state
)
else
{:noreply, state}
end
end
def update({:copy_selection}, state) do
if elem(
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.normalize_selection(
state
),
0
) != nil do
Raxol.UI.Components.Input.MultiLineInput.ClipboardHelper.copy_selection(
state
)
{:noreply, state}
else
{:noreply, state}
end
end
def update({:move_cursor_word_left}, state) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor(
state,
:word_left
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_word_right}, state) do
new_state =
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor(
state,
:word_right
)
|> Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.clear_selection()
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update({:move_cursor_select, direction}, state)
when direction in [
:left,
:right,
:up,
:down,
:line_start,
:line_end,
:page_up,
:page_down,
:doc_start,
:doc_end
] do
handle_selection_move(state, direction)
end
def update({:select_to, {row, col}}, state) do
original_cursor_pos = state.cursor_pos
selection_start = state.selection_start || original_cursor_pos
new_state = %{
state
| selection_start: selection_start,
selection_end: {row, col},
cursor_pos: {row, col}
}
{:noreply, ensure_cursor_visible(new_state), nil}
end
def update(msg, state) do
Raxol.Core.Runtime.Log.warning(
"[MultiLineInput] Unhandled update message: #{inspect(msg)}"
)
{:noreply, state, nil}
end
@doc """
Handles events for the MultiLineInput component, such as keypresses, mouse events, and context changes.
"""
@impl true
def handle_event(event, _context, state) do
# Delegate to the legacy EventHandler for translation
case Raxol.UI.Components.Input.MultiLineInput.EventHandler.handle_event(
event,
state
) do
{:update, msg, new_state} ->
# Call update/2 with the translated message
update(msg, new_state)
{:noreply, new_state, cmds} ->
{:noreply, new_state, cmds}
_other ->
# Fallback for any other return shape
{:noreply, state, nil}
end
end
# --- Internal Helpers ---
defp ensure_cursor_visible(state) do
{cursor_row, _cursor_col} = state.cursor_pos
{scroll_row, scroll_col} = state.scroll_offset
height = state.height
new_scroll_row =
cond do
cursor_row < scroll_row -> cursor_row
cursor_row >= scroll_row + height -> cursor_row - height + 1
true -> scroll_row
end
new_scroll_col = scroll_col
new_lines =
Raxol.UI.Components.Input.MultiLineInput.TextHelper.split_into_lines(
state.value,
state.width,
state.wrap
)
%{state | scroll_offset: {new_scroll_row, new_scroll_col}, lines: new_lines}
end
defp trigger_on_change({:noreply, new_state, existing_cmd}, old_state) do
if new_state.value != old_state.value and
is_function(new_state.on_change, 1) do
change_event_cmd =
{:component_event, new_state.id, {:change, new_state.value}}
new_cmd =
case existing_cmd do
nil -> [change_event_cmd]
cmd when is_list(cmd) -> [change_event_cmd | cmd]
single_cmd -> [change_event_cmd, single_cmd]
end
Raxol.Core.Runtime.Log.debug(
"Value changed for #{new_state.id}, queueing :change event."
)
{:noreply, new_state, new_cmd}
else
{:noreply, new_state, existing_cmd}
end
end
defp trigger_on_change(other, _old_state), do: other
defp handle_selection_move(state, direction) do
original_cursor_pos = state.cursor_pos
moved_state =
case direction do
:left ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor(
state,
:left
)
:right ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor(
state,
:right
)
:up ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor(
state,
:up
)
:down ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor(
state,
:down
)
:line_start ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_line_start(
state
)
:line_end ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_line_end(
state
)
:page_up ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_page(
state,
:up
)
:page_down ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_page(
state,
:down
)
:doc_start ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_doc_start(
state
)
:doc_end ->
Raxol.UI.Components.Input.MultiLineInput.NavigationHelper.move_cursor_doc_end(
state
)
end
new_cursor_pos = moved_state.cursor_pos
selection_start = state.selection_start || original_cursor_pos
final_state = %{
moved_state
| selection_start: selection_start,
selection_end: new_cursor_pos
}
{:noreply, ensure_cursor_visible(final_state), nil}
end
@doc """
Renders the MultiLineInput component using the current state and context.
"""
@spec render(__MODULE__.t(), map()) :: any()
@impl true
def render(state, context) do
# Merge themes: context.theme < state.theme
component_theme = Map.get(context.theme || %{}, :multi_line_input, %{})
merged_theme = Map.merge(component_theme, state.theme || %{})
# Calculate visible lines
start_row = state.scroll_offset |> elem(0)
end_row = min(start_row + state.height - 1, length(state.lines) - 1)
visible_lines = Enum.slice(state.lines, start_row..end_row)
# Render each line using the RenderHelper
line_elements =
Enum.with_index(visible_lines, start_row)
|> Enum.map(fn {line, index} ->
Raxol.UI.Components.Input.MultiLineInput.RenderHelper.render_line(
index,
line,
state,
%{components: %{multi_line_input: merged_theme}}
)
end)
# Render placeholder if needed
placeholder_element =
if state.value == "" and not state.focused and state.placeholder != "" do
Raxol.View.Elements.label(
content: state.placeholder,
style: [color: merged_theme[:placeholder_color] || :gray]
)
else
nil
end
# Compose children
children =
if placeholder_element != nil and visible_lines == [""] do
[placeholder_element]
else
line_elements
end
processed_children = children |> List.flatten() |> Enum.reject(&is_nil(&1))
# Compose root element with accessibility/extra props
root_props =
%{
style: merged_theme,
aria_label: state.aria_label,
tooltip: state.tooltip
}
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
Raxol.View.Elements.column root_props do
processed_children
end
end
end