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 graphics mouse_interaction.ex
Raw

lib/raxol/terminal/graphics/mouse_interaction.ex

defmodule Raxol.Terminal.Graphics.MouseInteraction do
@moduledoc """
Provides mouse interaction capabilities for terminal graphics elements.
This module enables:
- Click detection on graphics elements
- Hover events for graphics
- Drag and drop support for graphics
- Mouse gesture recognition
- Hit testing for complex graphics layouts
## Usage
# Register a graphics element for mouse interaction
MouseInteraction.register_interactive_element(graphics_id, %{
bounds: %{x: 10, y: 5, width: 100, height: 50},
callbacks: %{
on_click: &handle_click/1,
on_hover: &handle_hover/1
}
})
# Process mouse events
MouseInteraction.handle_mouse_event(%{
type: :click,
x: 50, y: 20,
button: :left
})
"""
use Raxol.Core.Behaviours.BaseManager
alias Raxol.Core.Runtime.Log
@type coordinates :: %{x: non_neg_integer(), y: non_neg_integer()}
@type bounds :: %{
x: non_neg_integer(),
y: non_neg_integer(),
width: non_neg_integer(),
height: non_neg_integer()
}
@type mouse_button :: :left | :right | :middle | :wheel_up | :wheel_down
@type mouse_event :: %{
type:
:click | :press | :release | :move | :hover | :drag | :context_menu,
x: non_neg_integer(),
y: non_neg_integer(),
button: mouse_button() | nil,
modifiers: map(),
timestamp: non_neg_integer()
}
@type interaction_callbacks :: %{
optional(:on_click) => function(),
optional(:on_hover) => function(),
optional(:on_drag_start) => function(),
optional(:on_drag) => function(),
optional(:on_drag_end) => function(),
optional(:on_double_click) => function(),
optional(:on_triple_click) => function(),
optional(:on_context_menu) => function(),
optional(:on_selection_start) => function(),
optional(:on_selection_change) => function(),
optional(:on_selection_end) => function()
}
@type interactive_element :: %{
graphics_id: non_neg_integer(),
bounds: bounds(),
callbacks: interaction_callbacks(),
z_index: integer(),
enabled: boolean(),
metadata: map()
}
defstruct [
:interactive_elements,
:hover_state,
:drag_state,
:selection_state,
:click_history,
:context_menu_state,
:config
]
@default_config %{
# milliseconds
double_click_timeout: 300,
# milliseconds
triple_click_timeout: 500,
# milliseconds
hover_delay: 100,
# pixels
drag_threshold: 3,
# pixels
selection_threshold: 1,
max_click_history: 50,
# Context menu configuration
context_menu_button: :right,
context_menu_delay: 0,
# Selection configuration
enable_text_selection: true,
# :character, :word, :line
selection_granularity: :character
}
# Public API
# start_link is provided by BaseManager
@doc """
Registers a graphics element for mouse interaction.
## Parameters
- `graphics_id` - ID of the graphics element
- `element_config` - Configuration including bounds, callbacks, etc.
## Examples
MouseInteraction.register_interactive_element(123, %{
bounds: %{x: 10, y: 5, width: 100, height: 50},
callbacks: %{
on_click: fn event -> Log.info("Clicked!") end,
on_hover: fn event -> Log.info("Hovered!") end
},
z_index: 1,
metadata: %{type: :button, id: "submit_btn"}
})
"""
@spec register_interactive_element(non_neg_integer(), map()) ::
:ok | {:error, term()}
def register_interactive_element(graphics_id, element_config) do
GenServer.call(__MODULE__, {:register_element, graphics_id, element_config})
end
@doc """
Unregisters a graphics element from mouse interaction.
"""
@spec unregister_interactive_element(non_neg_integer()) :: :ok
def unregister_interactive_element(graphics_id) do
GenServer.call(__MODULE__, {:unregister_element, graphics_id})
end
@doc """
Processes a mouse event and triggers appropriate callbacks.
## Parameters
- `mouse_event` - Mouse event data including type, coordinates, button
## Returns
- `{:handled, element_id}` - Event was handled by an interactive element
- `:not_handled` - No interactive element handled the event
"""
@spec handle_mouse_event(mouse_event()) ::
{:handled, non_neg_integer()} | :not_handled
def handle_mouse_event(mouse_event) do
GenServer.call(__MODULE__, {:handle_mouse_event, mouse_event})
end
@doc """
Updates the bounds of an interactive element.
"""
@spec update_element_bounds(non_neg_integer(), bounds()) ::
:ok | {:error, term()}
def update_element_bounds(graphics_id, new_bounds) do
GenServer.call(__MODULE__, {:update_bounds, graphics_id, new_bounds})
end
@doc """
Enables or disables mouse interaction for an element.
"""
@spec set_element_enabled(non_neg_integer(), boolean()) ::
:ok | {:error, term()}
def set_element_enabled(graphics_id, enabled) do
GenServer.call(__MODULE__, {:set_enabled, graphics_id, enabled})
end
@doc """
Gets all currently interactive elements.
"""
@spec get_interactive_elements() :: [interactive_element()]
def get_interactive_elements do
GenServer.call(__MODULE__, :get_elements)
end
@doc """
Gets the current selection state.
"""
@spec get_selection_state() :: map() | nil
def get_selection_state do
GenServer.call(__MODULE__, :get_selection_state)
end
@doc """
Clears the current selection.
"""
@spec clear_selection() :: :ok
def clear_selection do
GenServer.call(__MODULE__, :clear_selection)
end
@doc """
Gets the current drag state.
"""
@spec get_drag_state() :: map() | nil
def get_drag_state do
GenServer.call(__MODULE__, :get_drag_state)
end
@doc """
Sets text selection mode for an element.
"""
@spec set_text_selection_mode(non_neg_integer(), boolean()) ::
:ok | {:error, term()}
def set_text_selection_mode(graphics_id, enabled) do
GenServer.call(__MODULE__, {:set_text_selection, graphics_id, enabled})
end
@doc """
Updates configuration options for the mouse interaction manager.
"""
@spec update_config(map()) :: :ok
def update_config(config_updates) do
GenServer.call(__MODULE__, {:update_config, config_updates})
end
# GenServer Implementation
@impl Raxol.Core.Behaviours.BaseManager
def init_manager(opts) do
config = Map.merge(@default_config, Map.new(opts))
initial_state = %__MODULE__{
interactive_elements: %{},
hover_state: nil,
drag_state: nil,
selection_state: nil,
click_history: [],
context_menu_state: nil,
config: config
}
{:ok, initial_state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call(
{:register_element, graphics_id, element_config},
_from,
state
) do
case validate_element_config(element_config) do
:ok ->
element = build_interactive_element(graphics_id, element_config)
elements = Map.put(state.interactive_elements, graphics_id, element)
{:reply, :ok, %{state | interactive_elements: elements}}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call({:unregister_element, graphics_id}, _from, state) do
elements = Map.delete(state.interactive_elements, graphics_id)
{:reply, :ok, %{state | interactive_elements: elements}}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call({:handle_mouse_event, mouse_event}, _from, state) do
# Add timestamp if not present
event =
Map.put_new(mouse_event, :timestamp, System.system_time(:millisecond))
# Find elements under the mouse cursor
hit_elements =
find_elements_at_position(state.interactive_elements, {event.x, event.y})
# Process the event
{result, new_state} = process_mouse_event(event, hit_elements, state)
{:reply, result, new_state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call(
{:update_bounds, graphics_id, new_bounds},
_from,
state
) do
case Map.get(state.interactive_elements, graphics_id) do
nil ->
{:reply, {:error, :element_not_found}, state}
element ->
updated_element = %{element | bounds: new_bounds}
elements =
Map.put(state.interactive_elements, graphics_id, updated_element)
{:reply, :ok, %{state | interactive_elements: elements}}
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call({:set_enabled, graphics_id, enabled}, _from, state) do
case Map.get(state.interactive_elements, graphics_id) do
nil ->
{:reply, {:error, :element_not_found}, state}
element ->
updated_element = %{element | enabled: enabled}
elements =
Map.put(state.interactive_elements, graphics_id, updated_element)
{:reply, :ok, %{state | interactive_elements: elements}}
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call(:get_elements, _from, state) do
elements = Map.values(state.interactive_elements)
{:reply, elements, state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call(:get_selection_state, _from, state) do
{:reply, state.selection_state, state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call(:clear_selection, _from, state) do
{:reply, :ok, %{state | selection_state: nil}}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call(:get_drag_state, _from, state) do
{:reply, state.drag_state, state}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call(
{:set_text_selection, graphics_id, enabled},
_from,
state
) do
case Map.get(state.interactive_elements, graphics_id) do
nil ->
{:reply, {:error, :element_not_found}, state}
element ->
metadata = Map.put(element.metadata, :text_selection_enabled, enabled)
updated_element = %{element | metadata: metadata}
elements =
Map.put(state.interactive_elements, graphics_id, updated_element)
{:reply, :ok, %{state | interactive_elements: elements}}
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call({:update_config, config_updates}, _from, state) do
new_config = Map.merge(state.config, config_updates)
{:reply, :ok, %{state | config: new_config}}
end
# Private Functions
defp validate_element_config(config) do
case Map.get(config, :bounds) do
%{x: _, y: _, width: _, height: _} -> :ok
_ -> {:error, :invalid_bounds}
end
end
defp build_interactive_element(graphics_id, config) do
%{
graphics_id: graphics_id,
bounds: Map.get(config, :bounds),
callbacks: Map.get(config, :callbacks, %{}),
z_index: Map.get(config, :z_index, 0),
enabled: Map.get(config, :enabled, true),
metadata: Map.get(config, :metadata, %{})
}
end
defp find_elements_at_position(elements, {x, y}) do
elements
|> Map.values()
|> Enum.filter(fn element ->
element.enabled and point_in_bounds?({x, y}, element.bounds)
end)
# Higher z-index first
|> Enum.sort_by(& &1.z_index, :desc)
end
defp point_in_bounds?({x, y}, bounds) do
x >= bounds.x and x < bounds.x + bounds.width and
y >= bounds.y and y < bounds.y + bounds.height
end
defp process_mouse_event(event, hit_elements, state) do
case {event.type, hit_elements} do
{:click, [element | _]} ->
new_state = handle_click_event(event, element, state)
{{:handled, element.graphics_id}, new_state}
{:press, [element | _]} when event.button == :left ->
new_state = handle_press_event(event, element, state)
{{:handled, element.graphics_id}, new_state}
{:press, [element | _]} when event.button == :right ->
new_state = handle_context_menu_event(event, element, state)
{{:handled, element.graphics_id}, new_state}
{:release, _} ->
new_state = handle_release_event(event, state)
{:not_handled, new_state}
{:hover, [element | _]} ->
new_state = handle_hover_event(event, element, state)
{{:handled, element.graphics_id}, new_state}
{:move, hit_elements} ->
new_state = handle_move_event(event, hit_elements, state)
result =
case hit_elements do
[element | _] -> {:handled, element.graphics_id}
[] -> :not_handled
end
{result, new_state}
{:drag, hit_elements} ->
new_state = handle_drag_event(event, hit_elements, state)
result =
case hit_elements do
[element | _] -> {:handled, element.graphics_id}
[] -> :not_handled
end
{result, new_state}
_ ->
{:not_handled, state}
end
end
defp handle_click_event(event, element, state) do
# Check for multi-click
{click_type, updated_history} =
check_multi_click(event, state.click_history, state.config)
# Execute callback
case Map.get(element.callbacks, :on_click) do
callback when is_function(callback, 1) ->
try do
callback.(Map.put(event, :element, element))
rescue
error -> Log.warning("Click callback error: #{inspect(error)}")
end
_ ->
:ok
end
# Handle multi-click callbacks
case {click_type, element.callbacks} do
{:double, callbacks} when is_map_key(callbacks, :on_double_click) ->
callback = callbacks.on_double_click
try do
callback.(Map.put(event, :element, element))
rescue
error ->
Log.warning("Double-click callback error: #{inspect(error)}")
end
{:triple, callbacks} when is_map_key(callbacks, :on_triple_click) ->
callback = callbacks.on_triple_click
try do
callback.(Map.put(event, :element, element))
rescue
error ->
Log.warning("Triple-click callback error: #{inspect(error)}")
end
_ ->
:ok
end
%{state | click_history: updated_history}
end
defp handle_hover_event(event, element, state) do
# Only trigger if not already hovering this element
case state.hover_state do
^element ->
state
_ ->
# Execute hover callback
case Map.get(element.callbacks, :on_hover) do
callback when is_function(callback, 1) ->
try do
callback.(Map.put(event, :element, element))
rescue
error ->
Log.warning("Hover callback error: #{inspect(error)}")
end
_ ->
:ok
end
%{state | hover_state: element}
end
end
defp handle_drag_event(event, hit_elements, state) do
case state.drag_state do
nil ->
# Start drag operation
element = List.first(hit_elements)
new_drag_state = %{
start_x: event.x,
start_y: event.y,
current_x: event.x,
current_y: event.y,
started_at: event.timestamp,
element: element
}
# Trigger drag start callback
if element && Map.has_key?(element.callbacks, :on_drag_start) do
try do
element.callbacks.on_drag_start.(Map.put(event, :element, element))
rescue
error ->
Log.warning("Drag start callback error: #{inspect(error)}")
end
end
%{state | drag_state: new_drag_state}
drag_state ->
# Continue drag operation
updated_drag = %{drag_state | current_x: event.x, current_y: event.y}
# Trigger drag callback
if drag_state.element &&
Map.has_key?(drag_state.element.callbacks, :on_drag) do
try do
drag_info = %{
start: {drag_state.start_x, drag_state.start_y},
current: {event.x, event.y},
distance:
calculate_distance(
drag_state.start_x,
drag_state.start_y,
event.x,
event.y
)
}
drag_state.element.callbacks.on_drag.(
Map.merge(event, %{element: drag_state.element, drag: drag_info})
)
rescue
error ->
Log.warning("Drag callback error: #{inspect(error)}")
end
end
%{state | drag_state: updated_drag}
end
end
defp handle_press_event(event, element, state) do
# Start potential drag or selection
if element.metadata[:text_selection_enabled] &&
state.config.enable_text_selection do
new_selection_state = %{
start_x: event.x,
start_y: event.y,
current_x: event.x,
current_y: event.y,
element: element,
type: determine_selection_type(event, state.config)
}
# Trigger selection start callback
if Map.has_key?(element.callbacks, :on_selection_start) do
try do
element.callbacks.on_selection_start.(
Map.put(event, :element, element)
)
rescue
error ->
Log.warning("Selection start callback error: #{inspect(error)}")
end
end
%{state | selection_state: new_selection_state}
else
state
end
end
defp handle_release_event(event, state) do
new_state = state
# Handle drag end
new_state =
case new_state.drag_state do
nil ->
new_state
drag_state ->
if drag_state.element &&
Map.has_key?(drag_state.element.callbacks, :on_drag_end) do
try do
drag_info = %{
start: {drag_state.start_x, drag_state.start_y},
end: {event.x, event.y},
distance:
calculate_distance(
drag_state.start_x,
drag_state.start_y,
event.x,
event.y
)
}
drag_state.element.callbacks.on_drag_end.(
Map.merge(event, %{element: drag_state.element, drag: drag_info})
)
rescue
error ->
Log.warning("Drag end callback error: #{inspect(error)}")
end
end
%{new_state | drag_state: nil}
end
# Handle selection end
new_state =
case new_state.selection_state do
nil ->
new_state
selection_state ->
if Map.has_key?(selection_state.element.callbacks, :on_selection_end) do
try do
selection_info = %{
start: {selection_state.start_x, selection_state.start_y},
end: {event.x, event.y},
type: selection_state.type
}
selection_state.element.callbacks.on_selection_end.(
Map.merge(event, %{
element: selection_state.element,
selection: selection_info
})
)
rescue
error ->
Log.warning("Selection end callback error: #{inspect(error)}")
end
end
%{new_state | selection_state: nil}
end
new_state
end
defp handle_context_menu_event(event, element, state) do
# Show context menu
context_menu_state = %{
x: event.x,
y: event.y,
element: element,
timestamp: event.timestamp
}
# Trigger context menu callback
if Map.has_key?(element.callbacks, :on_context_menu) do
try do
element.callbacks.on_context_menu.(Map.put(event, :element, element))
rescue
error ->
Log.warning("Context menu callback error: #{inspect(error)}")
end
end
%{state | context_menu_state: context_menu_state}
end
# Updated handle_move_event to handle selection changes
defp handle_move_event(event, hit_elements, state) do
current_element = List.first(hit_elements)
# Handle hover state
new_hover_state =
case current_element do
nil -> nil
element -> element
end
# Handle selection changes
new_state =
case state.selection_state do
nil ->
state
selection_state ->
updated_selection = %{
selection_state
| current_x: event.x,
current_y: event.y
}
# Trigger selection change callback
if Map.has_key?(
selection_state.element.callbacks,
:on_selection_change
) do
try do
selection_info = %{
start: {selection_state.start_x, selection_state.start_y},
current: {event.x, event.y},
type: selection_state.type
}
selection_state.element.callbacks.on_selection_change.(
Map.merge(event, %{
element: selection_state.element,
selection: selection_info
})
)
rescue
error ->
Log.warning(
"Selection change callback error: #{inspect(error)}"
)
end
end
%{state | selection_state: updated_selection}
end
%{new_state | hover_state: new_hover_state}
end
defp determine_selection_type(_event, config) do
case config.selection_granularity do
:character -> :character
:word -> :word
:line -> :line
_ -> :character
end
end
defp calculate_distance(x1, y1, x2, y2) do
:math.sqrt(:math.pow(x2 - x1, 2) + :math.pow(y2 - y1, 2))
end
defp check_multi_click(event, history, config) do
# Add current click to history
new_history = [event | Enum.take(history, config.max_click_history - 1)]
# Check for multi-click patterns
click_type =
case new_history do
[current, second, third | _] ->
# Check for triple click
triple_time_diff = current.timestamp - third.timestamp
second_pos_diff =
abs(current.x - second.x) + abs(current.y - second.y)
third_pos_diff = abs(current.x - third.x) + abs(current.y - third.y)
if triple_time_diff <= config.triple_click_timeout and
second_pos_diff <= config.drag_threshold and
third_pos_diff <= config.drag_threshold do
:triple
else
check_double_click_in_history(current, second, config)
end
[current, second | _] ->
check_double_click_in_history(current, second, config)
_ ->
:single
end
{click_type, new_history}
end
defp check_double_click_in_history(current, previous, config) do
time_diff = current.timestamp - previous.timestamp
pos_diff = abs(current.x - previous.x) + abs(current.y - previous.y)
if time_diff <= config.double_click_timeout and
pos_diff <= config.drag_threshold do
:double
else
:single
end
end
end