Packages
Terminal emulation and driver infrastructure for Raxol. ANSI parsing, screen buffers, command processing, cursor management, input handling, session management, and termbox2 NIF integration.
Current section
Files
Jump to
Current section
Files
lib/raxol/terminal/screen_buffer/selection.ex
defmodule Raxol.Terminal.ScreenBuffer.Selection do
@moduledoc """
Text selection operations for the screen buffer.
Handles selection creation, updates, text extraction, and clipboard operations.
"""
alias Raxol.Terminal.Cell
alias Raxol.Terminal.ScreenBuffer.Core
alias Raxol.Terminal.ScreenBuffer.SharedOperations
@type selection ::
{non_neg_integer(), non_neg_integer(), non_neg_integer(), non_neg_integer()}
| nil
@doc """
Starts a new selection at the specified position.
"""
@spec start_selection(Core.t(), non_neg_integer(), non_neg_integer()) ::
Core.t()
def start_selection(buffer, x, y) do
%{buffer | selection: {x, y, x, y}}
end
@doc """
Extends the selection to the specified position.
Starts a new selection if none exists.
"""
@spec extend_selection(Core.t(), non_neg_integer(), non_neg_integer()) ::
Core.t()
def extend_selection(buffer, x, y) do
case buffer.selection do
{start_x, start_y, _, _} ->
%{buffer | selection: {start_x, start_y, x, y}}
nil ->
start_selection(buffer, x, y)
end
end
@doc """
Clears the current selection.
"""
@spec clear_selection(Core.t()) :: Core.t()
def clear_selection(buffer) do
%{buffer | selection: nil}
end
@doc """
Gets the current selection boundaries, normalized so start <= end.
"""
@spec get_selection(Core.t()) :: selection()
def get_selection(buffer) do
case buffer.selection do
nil -> nil
{x1, y1, x2, y2} -> SharedOperations.normalize_selection(x1, y1, x2, y2)
end
end
@doc """
Checks if there is an active selection.
"""
@spec has_selection?(Core.t()) :: boolean()
def has_selection?(buffer) do
buffer.selection != nil
end
@doc """
Checks if the specified position is within the current selection.
"""
@spec selected?(Core.t(), integer(), integer()) :: boolean()
def selected?(buffer, x, y) do
case get_selection(buffer) do
nil ->
false
{start_x, start_y, end_x, end_y} ->
SharedOperations.position_in_selection?(
x,
y,
start_x,
start_y,
end_x,
end_y
)
end
end
@doc """
Checks if a position is within the current selection.
Delegates to `selected?/3`.
"""
@spec position_in_selection?(Core.t(), integer(), integer()) :: boolean()
def position_in_selection?(buffer, x, y), do: selected?(buffer, x, y)
@doc """
Gets the selected text as a string.
"""
@spec get_selected_text(Core.t()) :: String.t()
def get_selected_text(buffer) do
case get_selection(buffer) do
nil ->
""
{start_x, start_y, end_x, end_y} ->
extract_text_region(buffer, start_x, start_y, end_x, end_y)
end
end
@doc """
Gets the selected text as lines.
"""
@spec get_selected_lines(Core.t()) :: [String.t()]
def get_selected_lines(buffer) do
case get_selection(buffer) do
nil ->
[]
{start_x, start_y, end_x, end_y} ->
extract_lines_region(buffer, start_x, start_y, end_x, end_y)
end
end
@doc """
Selects an entire line.
"""
@spec select_line(Core.t(), integer()) :: Core.t()
def select_line(buffer, y) when y >= 0 and y < buffer.height do
%{buffer | selection: {0, y, buffer.width - 1, y}}
end
def select_line(buffer, _y), do: buffer
@doc """
Selects multiple lines.
"""
@spec select_lines(Core.t(), integer(), integer()) :: Core.t()
def select_lines(buffer, start_y, end_y) do
start_y = max(0, min(start_y, buffer.height - 1))
end_y = max(0, min(end_y, buffer.height - 1))
%{buffer | selection: {0, start_y, buffer.width - 1, end_y}}
end
@doc """
Selects all content in the buffer.
"""
@spec select_all(Core.t()) :: Core.t()
def select_all(buffer) do
%{buffer | selection: {0, 0, buffer.width - 1, buffer.height - 1}}
end
@doc """
Selects a word at the given position.
"""
@spec select_word(Core.t(), integer(), integer()) :: Core.t()
def select_word(buffer, x, y) when x >= 0 and y >= 0 and y < buffer.height do
line = Core.get_line(buffer, y)
# Find word boundaries
{start_x, end_x} = find_word_boundaries(line, x)
%{buffer | selection: {start_x, y, end_x, y}}
end
def select_word(buffer, _x, _y), do: buffer
@doc """
Expands selection to word boundaries.
"""
@spec expand_selection_to_word(Core.t()) :: Core.t()
def expand_selection_to_word(buffer) do
case buffer.selection do
{x1, y1, x2, y2} ->
line1 = Core.get_line(buffer, y1)
line2 = Core.get_line(buffer, y2)
{start_x, _} = find_word_boundaries(line1, x1)
{_, end_x} = find_word_boundaries(line2, x2)
%{buffer | selection: {start_x, y1, end_x, y2}}
nil ->
buffer
end
end
# === Functions moved from main ScreenBuffer module ===
@doc """
Updates the selection endpoint. Returns buffer unchanged if no selection exists.
Unlike `extend_selection/3`, does not start a new selection on nil.
"""
@spec update_selection(Core.t(), non_neg_integer(), non_neg_integer()) ::
Core.t()
def update_selection(buffer, x, y) do
case buffer.selection do
{sx, sy, _, _} -> %{buffer | selection: {sx, sy, x, y}}
nil -> buffer
end
end
@spec get_selection_boundaries(Core.t()) ::
{{non_neg_integer(), non_neg_integer()}, {non_neg_integer(), non_neg_integer()}}
| nil
def get_selection_boundaries(buffer) do
case buffer.selection do
{sx, sy, ex, ey} -> {{sx, sy}, {ex, ey}}
nil -> nil
end
end
@doc "Delegates to `has_selection?/1`."
@spec selection_active?(Core.t()) :: boolean()
def selection_active?(buffer), do: has_selection?(buffer)
@spec get_selection_start(Core.t()) ::
{non_neg_integer(), non_neg_integer()} | nil
def get_selection_start(buffer) do
case buffer.selection do
{sx, sy, _, _} -> {sx, sy}
nil -> nil
end
end
@spec get_selection_end(Core.t()) ::
{non_neg_integer(), non_neg_integer()} | nil
def get_selection_end(buffer) do
case buffer.selection do
{sx, sy, sx, sy} -> nil
{_, _, ex, ey} -> {ex, ey}
nil -> nil
end
end
# Private helper functions
defp extract_text_region(buffer, start_x, start_y, end_x, end_y) do
lines = extract_lines_region(buffer, start_x, start_y, end_x, end_y)
Enum.join(lines, "\n")
end
defp extract_lines_region(buffer, start_x, start_y, end_x, end_y) do
for y <- start_y..end_y do
line = Core.get_line(buffer, y)
{from, to} =
line_slice_range(y, start_x, start_y, end_x, end_y, buffer.width)
text =
line
|> Enum.slice(from..to)
|> Enum.map_join("", &cell_to_char/1)
if start_y == end_y, do: text, else: String.trim_trailing(text)
end
end
defp line_slice_range(y, start_x, y, end_x, y, _width), do: {start_x, end_x}
defp line_slice_range(y, start_x, y, _end_x, _end_y, width),
do: {start_x, width - 1}
defp line_slice_range(y, _start_x, _start_y, end_x, y, _width), do: {0, end_x}
defp line_slice_range(_y, _start_x, _start_y, _end_x, _end_y, width),
do: {0, width - 1}
defp cell_to_char(%Cell{char: char}) when is_binary(char), do: char
defp cell_to_char(_), do: " "
defp find_word_boundaries(line, x) do
# Get character at position
char_at_x =
case Enum.at(line, x) do
%Cell{char: c} when is_binary(c) -> c
_ -> " "
end
# Determine if we're on a word character
if word_char?(char_at_x) do
# Find start of word
start_x = find_word_start(line, x)
# Find end of word
end_x = find_word_end(line, x)
{start_x, end_x}
else
# Not on a word, just select the position
{x, x}
end
end
defp find_word_start(line, x) do
Enum.reduce_while((x - 1)..0, x, fn i, _acc ->
check_word_start_char(Enum.at(line, i), i)
end)
end
defp find_word_end(line, x) do
max_x = length(line) - 1
Enum.reduce_while((x + 1)..max_x, x, fn i, _acc ->
check_word_end_char(Enum.at(line, i), i)
end)
end
defp word_char?(char) do
String.match?(char, ~r/[a-zA-Z0-9_]/)
end
defp check_word_start_char(cell, i) do
case cell do
%Cell{char: c} when is_binary(c) ->
if word_char?(c), do: {:cont, i}, else: {:halt, i + 1}
_ ->
{:halt, i + 1}
end
end
defp check_word_end_char(cell, i) do
case cell do
%Cell{char: c} when is_binary(c) ->
if word_char?(c), do: {:cont, i}, else: {:halt, i - 1}
_ ->
{:halt, i - 1}
end
end
end