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 terminal commands scrolling.ex
Raw

lib/raxol/terminal/commands/scrolling.ex

defmodule Raxol.Terminal.Commands.Scrolling do
@moduledoc """
Handles scrolling operations for the terminal screen buffer.
"""
import Raxol.Guards
alias Raxol.Terminal.ScreenBuffer
alias Raxol.Terminal.Cell
alias Raxol.Terminal.ANSI.TextFormatting
require Raxol.Core.Runtime.Log
@spec scroll_up(
ScreenBuffer.t(),
non_neg_integer(),
{integer(), integer()} | nil,
Raxol.Terminal.ANSI.TextFormatting.text_style()
) :: ScreenBuffer.t()
def scroll_up(buffer, _count, {region_top, region_bottom}, _blank_style)
when integer?(region_top) and integer?(region_bottom) and
region_top > region_bottom do
Raxol.Core.Runtime.Log.debug(
"Scroll Up: Invalid region (top > bottom). Region: #{inspect({region_top, region_bottom})}. No scroll."
)
buffer
end
def scroll_up(%{__struct__: _} = buffer, count, scroll_region, blank_style)
when count > 0 do
{effective_top, effective_bottom} = get_scroll_region(buffer, scroll_region)
region_height = effective_bottom - effective_top + 1
if count > 0 and region_height > 0 do
actual_scroll_count = min(count, region_height)
preserved_lines_source_start = effective_top + actual_scroll_count
_preserved_lines_count = region_height - actual_scroll_count
new_buffer =
shift_lines_up(
buffer,
effective_top,
preserved_lines_source_start,
region_height,
blank_style
)
%{
new_buffer
| scroll_position:
min(
(buffer.scroll_position || 0) + actual_scroll_count,
effective_bottom
)
}
else
buffer
end
end
def scroll_up(buffer, _count, _scroll_region, _blank_style)
when tuple?(buffer) do
raise ArgumentError,
"Expected buffer struct, got tuple (did you pass result of get_dimensions/1?)"
end
def scroll_up(buffer, count, _scroll_region, _blank_style) when count <= 0,
do: buffer
@spec scroll_down(
ScreenBuffer.t(),
non_neg_integer(),
{integer(), integer()} | nil,
Raxol.Terminal.ANSI.TextFormatting.text_style()
) :: ScreenBuffer.t()
def scroll_down(buffer, _count, {region_top, region_bottom}, _blank_style)
when integer?(region_top) and integer?(region_bottom) and
region_top > region_bottom do
Raxol.Core.Runtime.Log.debug(
"Scroll Down: Invalid region (top > bottom). Region: #{inspect({region_top, region_bottom})}. No scroll."
)
buffer
end
def scroll_down(%{__struct__: _} = buffer, count, scroll_region, blank_style)
when count > 0 do
{effective_top, effective_bottom} = get_scroll_region(buffer, scroll_region)
region_height = effective_bottom - effective_top + 1
if count > 0 and region_height > 0 do
actual_scroll_count = min(count, region_height)
new_buffer =
shift_lines_down(
buffer,
effective_top + actual_scroll_count,
effective_top,
region_height,
blank_style
)
%{
new_buffer
| scroll_position:
max(
(buffer.scroll_position || 0) - actual_scroll_count,
effective_top
)
}
else
buffer
end
end
def scroll_down(buffer, _count, _scroll_region, _blank_style)
when tuple?(buffer) do
raise ArgumentError,
"Expected buffer struct, got tuple (did you pass result of get_dimensions/1?)"
end
def scroll_down(buffer, count, _scroll_region, _blank_style) when count <= 0,
do: buffer
@spec insert_lines(
ScreenBuffer.t(),
non_neg_integer(),
non_neg_integer(),
non_neg_integer(),
non_neg_integer()
) :: ScreenBuffer.t()
def insert_lines(buffer, lines, y, top, bottom)
when is_struct(buffer, ScreenBuffer) and
is_integer(lines) and is_integer(y) and lines > 0 and
is_integer(top) and is_integer(bottom) do
# Ensure y is within the scroll region
y = max(top, min(y, bottom))
# Create blank lines with the default style
blank_style = buffer.default_style || TextFormatting.new()
blank_cell = Cell.new(" ", blank_style)
blank_line = List.duplicate(blank_cell, buffer.width)
blank_lines_to_insert = List.duplicate(blank_line, lines)
# Split the buffer cells at the insertion row
{top_part, bottom_part} = Enum.split(buffer.cells, y)
# Take only the lines from the bottom part that will fit within the scroll region
max_lines_in_region = bottom - top + 1
lines_after_insertion = y - top + lines
lines_to_keep = max(0, max_lines_in_region - lines_after_insertion)
# Keep lines from the bottom part that fit within the scroll region
kept_bottom_part = Enum.take(bottom_part, lines_to_keep)
# Add blank lines at the bottom of the scroll region if needed
remaining_lines =
max_lines_in_region - lines_after_insertion - lines_to_keep
additional_blank_lines =
if remaining_lines > 0 do
List.duplicate(blank_line, remaining_lines)
else
[]
end
# Combine the parts
new_cells =
top_part ++
blank_lines_to_insert ++ kept_bottom_part ++ additional_blank_lines
# Ensure we don't exceed the buffer height
final_cells = Enum.take(new_cells, buffer.height)
%{buffer | cells: final_cells}
end
defp get_scroll_region(buffer, scroll_region) do
case scroll_region do
{top, bottom}
when integer?(top) and integer?(bottom) and top >= 0 and
bottom <= buffer.height ->
{top, bottom}
_ ->
case Raxol.Terminal.Buffer.ScrollRegion.get_region(buffer) do
{top, bottom} -> {top, bottom}
nil -> {0, buffer.height - 1}
end
end
end
defp shift_lines_up(
buffer,
region_start,
region_start_plus_n,
region_height,
blank_style
) do
cells = buffer.cells
_region_end = region_start + region_height - 1
n = region_start_plus_n - region_start
new_cells =
Enum.with_index(cells)
|> Enum.map(fn {line, idx} ->
map_line_for_shift_up(
idx,
cells,
line,
region_start,
region_start + region_height - 1,
n,
buffer.width,
blank_style
)
end)
%{buffer | cells: new_cells}
end
defp map_line_for_shift_up(
idx,
cells,
line,
region_start,
region_end,
n,
width,
blank_style
) do
cond do
idx >= region_start and idx <= region_end - n ->
get_source_line(cells, idx + n, line)
idx > region_end - n and idx <= region_end ->
# Create empty line for this position
List.duplicate(Cell.new(" ", blank_style), width)
true ->
line
end
end
defp shift_lines_down(
buffer,
region_start_plus_n,
region_start,
count,
blank_style
) do
cells = buffer.cells
region_height = count
n = region_start_plus_n - region_start
region_end = region_start + region_height - 1
new_cells =
Enum.with_index(cells)
|> Enum.map(fn {line, idx} ->
map_line_for_shift_down(
idx,
cells,
line,
region_start,
region_end,
n,
buffer.width,
blank_style
)
end)
%{buffer | cells: new_cells}
end
defp map_line_for_shift_down(
idx,
cells,
line,
region_start,
region_end,
n,
width,
blank_style
) do
cond do
idx >= region_start + n and idx <= region_end ->
get_source_line(cells, idx - n, line)
idx >= region_start and idx < region_start + n ->
# Create empty line for this position
List.duplicate(Cell.new(" ", blank_style), width)
true ->
line
end
end
defp get_source_line(cells, source_idx, fallback_line) do
if source_idx >= 0 and source_idx < length(cells) do
Enum.at(cells, source_idx) || fallback_line
else
fallback_line
end
end
end