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
Current section
Files
lib/raxol/terminal/input/mouse_handler.ex
defmodule Raxol.Terminal.Input.MouseHandler do
@moduledoc """
Comprehensive mouse event handling for terminal applications.
Supports multiple mouse protocols:
- X10: Original mouse tracking (button press only)
- X11: Mouse tracking with button release
- SGR: Extended mouse protocol with precise coordinates
- URXVT: Extended protocol variant
## Features
- Button press/release detection
- Mouse movement tracking
- Scroll wheel support
- Drag operations
- Multi-button chord detection
- High-precision coordinate reporting (SGR mode)
## Usage
# Parse a mouse event sequence
MouseHandler.parse_mouse_event("\e[<0;10;20M")
{:ok, %{type: :press, button: :left, x: 10, y: 20}}
# Enable mouse tracking
MouseHandler.enable_mouse_tracking(:sgr)
# Handle mouse event
MouseHandler.handle_event(state, event)
"""
require Logger
import Bitwise
@type button ::
:left
| :middle
| :right
| :wheel_up
| :wheel_down
| :button4
| :button5
| :button6
| :button7
| :button8
| :button9
| :button10
| :button11
@type event_type :: :press | :release | :move | :drag | :scroll
@type mouse_event :: %{
type: event_type(),
button: button() | nil,
x: non_neg_integer(),
y: non_neg_integer(),
modifiers: map(),
protocol: atom(),
timestamp: non_neg_integer()
}
@type mouse_mode ::
:off | :x10 | :x11 | :button_event | :any_event | :sgr | :urxvt
@type state :: %{
mode: mouse_mode(),
pressed_buttons: MapSet.t(button()),
last_position: {non_neg_integer(), non_neg_integer()} | nil,
drag_start: {non_neg_integer(), non_neg_integer()} | nil,
click_count: non_neg_integer(),
last_click_time: non_neg_integer() | nil,
double_click_threshold: non_neg_integer()
}
# Protocol-specific parsing patterns
@x10_pattern ~r/\e\[M(.)(.)(.)/
@sgr_pattern ~r/\e\[<(\d+);(\d+);(\d+)([Mm])/
@urxvt_pattern ~r/\e\[(\d+);(\d+);(\d+)M/
# SGR protocol button codes
@sgr_buttons %{
0 => :left,
1 => :middle,
2 => :right,
64 => :wheel_up,
65 => :wheel_down,
128 => :button8,
129 => :button9,
130 => :button10,
131 => :button11
}
@doc """
Creates a new mouse handler state.
"""
def new(opts \\ []) do
%{
mode: Keyword.get(opts, :mode, :off),
pressed_buttons: MapSet.new(),
last_position: nil,
drag_start: nil,
click_count: 0,
last_click_time: nil,
double_click_threshold: Keyword.get(opts, :double_click_threshold, 500)
}
end
@doc """
Enables mouse tracking with the specified mode.
Returns the escape sequence to send to the terminal.
"""
def enable_mouse_tracking(mode) do
case mode do
# X10 compatibility mode
:x10 -> "\e[?9h"
# Normal tracking mode
:x11 -> "\e[?1000h"
# Button event tracking
:button_event -> "\e[?1002h"
# Any event tracking
:any_event -> "\e[?1003h"
# SGR extended mode
:sgr -> "\e[?1006h"
# URXVT extended mode
:urxvt -> "\e[?1015h"
:off -> disable_mouse_tracking()
_ -> ""
end
end
@doc """
Disables all mouse tracking modes.
"""
def disable_mouse_tracking do
# Disable all possible modes
"\e[?9l\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1015l"
end
@doc """
Parses a mouse event sequence from terminal input.
Automatically detects the protocol used and returns a parsed event.
"""
def parse_mouse_event(sequence) when is_binary(sequence) do
cond do
String.match?(sequence, @sgr_pattern) ->
parse_sgr_event(sequence)
String.match?(sequence, @urxvt_pattern) ->
parse_urxvt_event(sequence)
String.match?(sequence, @x10_pattern) ->
parse_x10_event(sequence)
true ->
{:error, :unknown_mouse_sequence}
end
end
# SGR extended protocol parser
defp parse_sgr_event(sequence) do
case Regex.run(@sgr_pattern, sequence) do
[_, button_str, x_str, y_str, action] ->
button_code = String.to_integer(button_str)
x = String.to_integer(x_str)
y = String.to_integer(y_str)
{button, modifiers} = decode_sgr_button(button_code)
type = if action == "M", do: :press, else: :release
event = %{
type: type,
button: button,
# Convert to 0-based
x: x - 1,
# Convert to 0-based
y: y - 1,
modifiers: modifiers,
protocol: :sgr,
timestamp: System.monotonic_time(:millisecond)
}
{:ok, enhance_event_with_type(event)}
_ ->
{:error, :invalid_sgr_sequence}
end
end
# URXVT extended protocol parser
defp parse_urxvt_event(sequence) do
case Regex.run(@urxvt_pattern, sequence) do
[_, button_str, x_str, y_str] ->
button_code = String.to_integer(button_str)
x = String.to_integer(x_str)
y = String.to_integer(y_str)
{button, modifiers} = decode_urxvt_button(button_code)
event = %{
# URXVT doesn't distinguish press/release
type: :press,
button: button,
x: x - 1,
y: y - 1,
modifiers: modifiers,
protocol: :urxvt,
timestamp: System.monotonic_time(:millisecond)
}
{:ok, event}
_ ->
{:error, :invalid_urxvt_sequence}
end
end
# X10/X11 protocol parser
defp parse_x10_event(sequence) do
case Regex.run(@x10_pattern, sequence) do
[_, button_byte, x_byte, y_byte] ->
<<button_code>> = button_byte
<<x_raw>> = x_byte
<<y_raw>> = y_byte
# Subtract 32 offset used in X10 protocol
button_info = button_code - 32
# 32 offset + 1-based to 0-based
x = x_raw - 33
y = y_raw - 33
{button, modifiers, type} = decode_x10_button(button_info)
event = %{
type: type,
button: button,
x: x,
y: y,
modifiers: modifiers,
protocol: :x10,
timestamp: System.monotonic_time(:millisecond)
}
{:ok, event}
_ ->
{:error, :invalid_x10_sequence}
end
end
# Decode SGR button codes with modifiers
defp decode_sgr_button(code) do
# Check if this is a wheel event first (codes 64, 65)
if code in [64, 65] do
button = Map.get(@sgr_buttons, code)
modifiers = %{shift: false, alt: false, ctrl: false}
{button, modifiers}
else
# Handle regular buttons with modifiers
# Only 4 basic buttons: 0,1,2,3
base_button = rem(code, 4)
modifier_bits = div(code, 4)
button =
case base_button do
0 -> :left
1 -> :middle
2 -> :right
3 -> :release
end
modifiers = %{
shift: band(modifier_bits, 1) == 1,
alt: band(modifier_bits, 2) == 2,
ctrl: band(modifier_bits, 4) == 4
}
{button, modifiers}
end
end
# Decode URXVT button codes
defp decode_urxvt_button(code) do
# URXVT uses similar encoding to X10 but with extended range
# Code 36 = 36 - 32 = 4, which is left button with shift modifier
adjusted_code = code - 32
# Extract button and modifiers
# For code 36: adjusted = 4, button_bits = 0 (left), modifier = 4 (shift)
button_bits = band(adjusted_code, 0b00000011)
modifier_bits = band(adjusted_code, 0b00011100)
button =
case button_bits do
0 -> :left
1 -> :middle
2 -> :right
3 -> :release
end
# Check for wheel events (different encoding)
button = if adjusted_code == 64, do: :wheel_up, else: button
button = if adjusted_code == 65, do: :wheel_down, else: button
modifiers = %{
shift: band(modifier_bits, 0b00000100) != 0,
alt: band(modifier_bits, 0b00001000) != 0,
ctrl: band(modifier_bits, 0b00010000) != 0
}
{button, modifiers}
end
# Decode X10/X11 button codes
defp decode_x10_button(code) do
# Extract button and modifier information
button_bits = band(code, 0b00000011)
modifier_bits = band(code, 0b11111100)
# Check for motion events
motion = band(code, 32) == 32
# Determine button
button =
if motion do
# Motion without button
nil
else
case button_bits do
0 -> :left
1 -> :middle
2 -> :right
3 -> :release
end
end
# Determine event type
type =
cond do
button == :release -> :release
motion -> :move
# Wheel events
band(code, 64) == 64 -> :scroll
true -> :press
end
# Handle wheel events specially
button =
if type == :scroll do
if band(code, 1) == 0, do: :wheel_up, else: :wheel_down
else
button
end
modifiers = %{
shift: band(modifier_bits, 4) == 4,
alt: band(modifier_bits, 8) == 8,
ctrl: band(modifier_bits, 16) == 16
}
{button, modifiers, type}
end
# Enhance event with additional type information
# Note: This is only called from parse_sgr_event where type is :press or :release
defp enhance_event_with_type(event) do
if event.button in [:wheel_up, :wheel_down] do
%{event | type: :scroll}
else
event
end
end
@doc """
Processes a mouse event and updates the handler state.
Tracks button states, detects drag operations, and counts clicks.
"""
def handle_event(state, event) do
new_state =
state
|> update_button_state(event)
|> update_position(event)
|> detect_drag(event)
|> detect_multi_click(event)
actions = generate_actions(new_state, event)
{new_state, actions}
end
defp update_button_state(
state,
%{type: :press, button: button, x: x, y: y} = _event
)
when not is_nil(button) do
%{
state
| pressed_buttons: MapSet.put(state.pressed_buttons, button),
# Set drag_start when button is pressed
drag_start: {x, y}
}
end
defp update_button_state(state, %{type: :release, button: button} = _event)
when not is_nil(button) do
%{
state
| pressed_buttons: MapSet.delete(state.pressed_buttons, button),
drag_start: nil
}
end
defp update_button_state(state, _event), do: state
defp update_position(state, %{x: x, y: y} = _event) do
%{state | last_position: {x, y}}
end
defp detect_drag(state, %{type: :move} = _event) do
if state.drag_start != nil && MapSet.size(state.pressed_buttons) > 0 do
# Already in drag mode
state
else
state
end
end
defp detect_drag(state, %{type: :release} = _event) do
%{state | drag_start: nil}
end
defp detect_drag(state, _event), do: state
defp detect_multi_click(
state,
%{type: :press, x: x, y: y, timestamp: event_time} = _event
) do
now = event_time
{click_count, last_click_time} =
case state.last_click_time do
nil ->
{1, now}
last_time when now - last_time <= state.double_click_threshold ->
# Check if click is in same location (within 3 pixels)
case state.last_position do
{last_x, last_y}
when abs(x - last_x) <= 3 and abs(y - last_y) <= 3 ->
{state.click_count + 1, now}
_ ->
{1, now}
end
_ ->
{1, now}
end
%{state | click_count: click_count, last_click_time: last_click_time}
end
defp detect_multi_click(state, _event), do: state
defp generate_actions(state, event) do
actions = []
actions =
if state.click_count == 2 do
[{:double_click, event} | actions]
else
actions
end
actions =
if state.click_count == 3 do
[{:triple_click, event} | actions]
else
actions
end
actions =
if state.drag_start && event.type == :move do
[
{:drag, %{start: state.drag_start, current: {event.x, event.y}}}
| actions
]
else
actions
end
actions =
if MapSet.size(state.pressed_buttons) > 1 do
[{:chord, MapSet.to_list(state.pressed_buttons)} | actions]
else
actions
end
actions
end
@doc """
Generates control sequences to set mouse reporting modes.
"""
def set_mouse_mode(mode, enable \\ true) do
sequences =
case mode do
# X10 compatibility
:click_only -> [9]
# Normal tracking
:click_drag -> [1000]
# Button event tracking
:button_events -> [1002]
# Any event tracking
:all_events -> [1003]
# SGR extended
:sgr_extended -> [1006]
# URXVT extended
:urxvt_extended -> [1015]
# Focus in/out events
:focus_events -> [1004]
_ -> []
end
sequences
|> Enum.map_join(fn code ->
if enable do
"\e[?#{code}h"
else
"\e[?#{code}l"
end
end)
end
@doc """
Returns the optimal mouse mode for the current terminal.
Detects terminal capabilities and returns the best supported mode.
"""
def detect_best_mode do
# Check terminal environment variables
term = System.get_env("TERM", "")
term_program = System.get_env("TERM_PROGRAM", "")
cond do
# URXVT and derivatives (check first before 256color)
String.contains?(term, "rxvt") ->
:urxvt
# Modern terminals with good SGR support
String.contains?(term, ["xterm-256color", "screen-256color", "tmux"]) ->
:sgr
# Known terminals with SGR support
term_program in ["iTerm.app", "Terminal.app", "WezTerm", "Alacritty"] ->
:sgr
# Basic xterm
String.starts_with?(term, "xterm") ->
:x11
# Unknown or basic terminals
true ->
:x10
end
end
end