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 effects cursor_trail.ex
Raw

lib/raxol/effects/cursor_trail.ex

defmodule Raxol.Effects.CursorTrail do
@moduledoc """
Visual cursor trail effects for terminal interfaces.
Creates a fading trail behind cursor movements for enhanced visual feedback.
Useful for:
- Drawing attention to cursor position
- Visualizing movement patterns
- Improving accessibility
- Creating engaging UIs
## Example
trail = CursorTrail.new(max_length: 10)
trail = CursorTrail.update(trail, {5, 10})
trail = CursorTrail.update(trail, {6, 10})
trail = CursorTrail.update(trail, {7, 10})
# Apply trail to buffer
buffer = CursorTrail.apply(trail, buffer)
## Configuration
config = %{
max_length: 20, # Maximum trail positions to track
decay_rate: 0.15, # How quickly trail fades (0.0-1.0)
colors: [:cyan, :blue, :magenta],
chars: ["*", "+", "."], # Characters for different trail positions
min_opacity: 0.1 # Minimum opacity before hiding
}
trail = CursorTrail.new(config)
"""
alias Raxol.Core.Buffer
@type position :: {non_neg_integer(), non_neg_integer()}
@type trail_point :: %{
position: position(),
age: non_neg_integer(),
opacity: float()
}
@type config :: %{
optional(:max_length) => pos_integer(),
optional(:decay_rate) => float(),
optional(:colors) => list(atom()),
optional(:chars) => list(String.t()),
optional(:min_opacity) => float(),
optional(:enabled) => boolean()
}
@type t :: %__MODULE__{
points: list(trail_point()),
config: config(),
tick: non_neg_integer()
}
defstruct points: [],
config: %{},
tick: 0
@default_config %{
max_length: 15,
decay_rate: 0.12,
colors: [:cyan, :blue, :magenta, :white],
chars: ["*", "+", ".", ":"],
min_opacity: 0.15,
enabled: true
}
@doc """
Create a new cursor trail effect.
"""
@spec new(config()) :: t()
def new(config \\ %{}) do
merged_config = Map.merge(@default_config, config)
%__MODULE__{
points: [],
config: merged_config,
tick: 0
}
end
@doc """
Update trail with new cursor position.
"""
@spec update(t(), position()) :: t()
def update(%{config: %{enabled: false}} = trail, _position), do: trail
def update(%{points: points, config: config, tick: tick} = trail, position) do
# Don't add if position hasn't changed
new_points =
case points do
[%{position: ^position} | _] ->
# Same position, just age existing points
age_points(points)
_ ->
# New position, add to trail
new_point = %{
position: position,
age: 0,
opacity: 1.0
}
[new_point | age_points(points)]
|> Enum.take(config.max_length)
|> filter_visible(config.min_opacity)
end
%{trail | points: new_points, tick: tick + 1}
end
@doc """
Apply trail effect to buffer.
"""
@spec apply(t(), Buffer.t()) :: Buffer.t()
def apply(%{config: %{enabled: false}}, buffer), do: buffer
def apply(%{points: points, config: config}, buffer) do
points
|> Enum.reverse()
|> Enum.with_index()
|> Enum.reduce(buffer, fn {point, idx}, buf ->
apply_point(buf, point, idx, config)
end)
end
@doc """
Clear all trail points.
"""
@spec clear(t()) :: t()
def clear(trail) do
%{trail | points: [], tick: 0}
end
@doc """
Enable or disable trail effect.
"""
@spec set_enabled(t(), boolean()) :: t()
def set_enabled(trail, enabled) do
put_in(trail.config.enabled, enabled)
end
@doc """
Update trail configuration.
"""
@spec update_config(t(), config()) :: t()
def update_config(%{config: current_config} = trail, new_config) do
merged = Map.merge(current_config, new_config)
%{trail | config: merged}
end
@doc """
Get current trail length.
"""
@spec length(t()) :: non_neg_integer()
def length(%{points: points}), do: Kernel.length(points)
@doc """
Get trail statistics.
"""
@spec stats(t()) :: map()
def stats(%{points: points, config: config, tick: tick}) do
%{
point_count: Kernel.length(points),
max_length: config.max_length,
enabled: config.enabled,
tick: tick,
average_opacity:
if Kernel.length(points) > 0 do
Enum.sum(Enum.map(points, & &1.opacity)) / Kernel.length(points)
else
0.0
end
}
end
# Private functions
defp age_points(points) do
points
|> Enum.map(fn point ->
%{point | age: point.age + 1}
end)
end
defp filter_visible(points, min_opacity) do
points
|> Enum.map(fn point ->
opacity = calculate_opacity(point.age)
%{point | opacity: opacity}
end)
|> Enum.filter(&(&1.opacity >= min_opacity))
end
defp calculate_opacity(age) do
# Exponential decay formula
max(0.0, :math.exp(-age * 0.15))
end
defp apply_point(buffer, %{position: {x, y}, opacity: opacity}, idx, config) do
# Bounds check
if x >= 0 and x < buffer.width and y >= 0 and y < buffer.height do
char = select_char(idx, config.chars)
color = select_color(idx, config.colors)
style = create_style(color, opacity)
# Get existing cell to preserve content if needed
case Buffer.get_cell(buffer, x, y) do
nil ->
Buffer.set_cell(buffer, x, y, char, style)
existing_cell ->
# Only apply if cell is empty or trail should override
if existing_cell.char == " " or String.trim(existing_cell.char) == "" do
Buffer.set_cell(buffer, x, y, char, style)
else
buffer
end
end
else
buffer
end
end
defp select_char(idx, chars) do
char_idx = rem(idx, Kernel.length(chars))
Enum.at(chars, char_idx)
end
defp select_color(idx, colors) do
color_idx = rem(div(idx, 2), Kernel.length(colors))
Enum.at(colors, color_idx)
end
defp create_style(color, opacity) do
# Convert opacity to intensity (simplified for terminal)
intensity =
cond do
opacity > 0.7 -> :bright
opacity > 0.4 -> :normal
true -> :dim
end
%{
fg_color: color,
intensity: intensity,
bold: opacity > 0.8
}
end
@doc """
Create animated trail with multiple cursors.
"""
@spec multi_cursor(list(position()), config()) :: t()
def multi_cursor(positions, config \\ %{}) do
trail = new(config)
positions
|> Enum.reduce(trail, fn pos, t ->
update(t, pos)
end)
end
@doc """
Interpolate positions for smooth trail.
Adds intermediate points between cursor positions for smoother trails.
"""
@spec interpolate(t(), position(), position()) :: t()
def interpolate(trail, from, to) do
positions = bresenham_line(from, to)
positions
|> Enum.reduce(trail, fn pos, t ->
update(t, pos)
end)
end
# Bresenham's line algorithm for smooth interpolation
defp bresenham_line({x0, y0}, {x1, y1}) do
dx = abs(x1 - x0)
dy = abs(y1 - y0)
sx = if x0 < x1, do: 1, else: -1
sy = if y0 < y1, do: 1, else: -1
do_bresenham(x0, y0, x1, y1, dx, dy, sx, sy, dx - dy, [])
end
defp do_bresenham(x, y, x1, y1, _dx, _dy, _sx, _sy, _err, acc)
when x == x1 and y == y1 do
Enum.reverse([{x, y} | acc])
end
defp do_bresenham(x, y, x1, y1, dx, dy, sx, sy, err, acc) do
e2 = 2 * err
{new_x, new_err_x} =
if e2 > -dy do
{x + sx, err - dy}
else
{x, err}
end
{new_y, new_err_y} =
if e2 < dx do
{y + sy, new_err_x + dx}
else
{y, new_err_x}
end
do_bresenham(new_x, new_y, x1, y1, dx, dy, sx, sy, new_err_y, [{x, y} | acc])
end
@doc """
Create rainbow trail effect.
"""
@spec rainbow(config()) :: t()
def rainbow(config \\ %{}) do
rainbow_config =
Map.merge(config, %{
colors: [:red, :yellow, :green, :cyan, :blue, :magenta],
chars: ["*", "*", "*", "*", "*", "*"],
max_length: 24
})
new(rainbow_config)
end
@doc """
Create minimal trail effect.
"""
@spec minimal(config()) :: t()
def minimal(config \\ %{}) do
minimal_config =
Map.merge(config, %{
colors: [:white],
chars: ["."],
max_length: 5,
decay_rate: 0.3
})
new(minimal_config)
end
@doc """
Create comet trail effect (long fading tail).
"""
@spec comet(config()) :: t()
def comet(config \\ %{}) do
comet_config =
Map.merge(config, %{
colors: [:white, :cyan, :blue],
chars: ["*", "*", "+", "+", ".", ".", ":"],
max_length: 30,
decay_rate: 0.08,
min_opacity: 0.05
})
new(comet_config)
end
@doc """
Apply glow effect to current cursor position.
"""
@spec apply_glow(Buffer.t(), position(), atom()) :: Buffer.t()
def apply_glow(buffer, {x, y}, color \\ :cyan) do
# Center glow
buffer = apply_glow_point(buffer, {x, y}, color, 1.0)
# Surrounding glow (8 directions)
offsets = [
{-1, -1},
{0, -1},
{1, -1},
{-1, 0},
{1, 0},
{-1, 1},
{0, 1},
{1, 1}
]
offsets
|> Enum.reduce(buffer, fn {dx, dy}, buf ->
apply_glow_point(buf, {x + dx, y + dy}, color, 0.3)
end)
end
defp apply_glow_point(buffer, {x, y}, color, opacity) do
if x >= 0 and x < buffer.width and y >= 0 and y < buffer.height do
style = create_style(color, opacity)
case Buffer.get_cell(buffer, x, y) do
nil ->
Buffer.set_cell(buffer, x, y, " ", style)
existing_cell ->
merged_style = Map.merge(existing_cell.style, style)
Buffer.set_cell(buffer, x, y, existing_cell.char, merged_style)
end
else
buffer
end
end
end