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
raxol_terminal lib raxol terminal screen_buffer selection.ex
Raw

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