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