TermUI delivers terminal input as structured events to your components. This guide covers all event types and how to handle them.

Event Types

Key Events

Keyboard input including regular characters, special keys, and modifier combinations.

%Event.Key{
  key: :enter,           # Atom for special keys, string for characters
  char: nil,             # Character string (nil for special keys)
  modifiers: [:ctrl],    # List of :ctrl, :alt, :shift
  timestamp: 123456789   # Monotonic time in milliseconds
}

Special Keys:

KeyAtom
Enter:enter
Escape:escape
Tab:tab
Backspace:backspace
Delete:delete
Insert:insert
Home:home
End:end
Page Up:page_up
Page Down:page_down
Arrow Up:up
Arrow Down:down
Arrow Left:left
Arrow Right:right
F1-F12:f1 through :f12

Character Keys:

Regular characters are delivered as strings:

%Event.Key{key: "a", char: "a"}     # Lowercase a
%Event.Key{key: "A", char: "A"}     # Uppercase A (shift held)
%Event.Key{key: " ", char: " "}     # Space
%Event.Key{key: "1", char: "1"}     # Number 1

Handling Key Events:

# Match special keys
def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :submit}
def event_to_msg(%Event.Key{key: :escape}, _state), do: {:msg, :cancel}

# Match characters (case-insensitive)
def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do
  {:msg, :quit}
end

# Match with modifiers
def event_to_msg(%Event.Key{key: "s", modifiers: [:ctrl]}, _state) do
  {:msg, :save}
end

# Match any character for text input
def event_to_msg(%Event.Key{char: char}, state) when is_binary(char) do
  {:msg, {:char_input, char}}
end

# Ignore unhandled keys
def event_to_msg(%Event.Key{}, _state), do: :ignore

Mouse Events

Mouse clicks, movement, and scrolling.

%Event.Mouse{
  action: :click,        # :click, :double_click, :press, :release, :drag, :move
  button: :left,         # :left, :middle, :right, or nil
  x: 10,                 # Column (0-indexed)
  y: 5,                  # Row (0-indexed)
  modifiers: [],         # :ctrl, :alt, :shift
  timestamp: 123456789
}

Mouse Actions:

ActionDescription
:pressButton pressed down
:releaseButton released
:clickPress and release
:double_clickTwo clicks in quick succession
:dragMovement with button held
:moveMovement without button
:scroll_upScroll wheel up
:scroll_downScroll wheel down

Handling Mouse Events:

def event_to_msg(%Event.Mouse{action: :click, x: x, y: y}, _state) do
  {:msg, {:click, x, y}}
end

def event_to_msg(%Event.Mouse{action: :scroll_up}, _state) do
  {:msg, :scroll_up}
end

def event_to_msg(%Event.Mouse{action: :scroll_down}, _state) do
  {:msg, :scroll_down}
end

def event_to_msg(%Event.Mouse{action: :drag, x: x, y: y}, _state) do
  {:msg, {:drag, x, y}}
end

Mouse Tracking Modes:

Mouse events require enabling mouse tracking:

# In Terminal setup (done automatically by Runtime)
Terminal.enable_mouse_tracking(:click)  # Click events only
Terminal.enable_mouse_tracking(:drag)   # Click and drag
Terminal.enable_mouse_tracking(:all)    # All movement

Resize Events

Terminal window size changes.

%Event.Resize{
  width: 120,            # New column count
  height: 40,            # New row count
  timestamp: 123456789
}

Handling Resize:

def event_to_msg(%Event.Resize{width: w, height: h}, _state) do
  {:msg, {:resize, w, h}}
end

def update({:resize, width, height}, state) do
  {%{state | width: width, height: height}, []}
end

Focus Events

Terminal window focus changes.

%Event.Focus{
  action: :gained,       # :gained or :lost
  timestamp: 123456789
}

Handling Focus:

def event_to_msg(%Event.Focus{action: :gained}, _state) do
  {:msg, :focus_gained}
end

def event_to_msg(%Event.Focus{action: :lost}, _state) do
  {:msg, :focus_lost}
end

def update(:focus_lost, state) do
  # Pause animations, save state, etc.
  {%{state | paused: true}, []}
end

Paste Events

Text pasted from clipboard (with bracketed paste mode).

%Event.Paste{
  content: "pasted text",
  timestamp: 123456789
}

Handling Paste:

def event_to_msg(%Event.Paste{content: text}, _state) do
  {:msg, {:paste, text}}
end

def update({:paste, text}, state) do
  {%{state | input: state.input <> text}, []}
end

Tick Events

Timer-based periodic events.

%Event.Tick{
  interval: 1000,        # Interval in milliseconds
  timestamp: 123456789
}

These are typically generated by commands rather than received directly.

Custom Events

Application-defined events.

%Event.Custom{
  name: :data_loaded,
  payload: %{items: [...]},
  timestamp: 123456789
}

Event Handling Patterns

Catch-All Handler

Always include a catch-all to handle unexpected events:

def event_to_msg(_, _state), do: :ignore

Conditional Handling

Handle events differently based on state:

def event_to_msg(%Event.Key{key: :enter}, %{mode: :edit}) do
  {:msg, :confirm_edit}
end

def event_to_msg(%Event.Key{key: :enter}, %{mode: :view}) do
  {:msg, :start_edit}
end

Key Sequences

Track key sequences for shortcuts:

def init(_opts) do
  %{key_buffer: []}
end

def event_to_msg(%Event.Key{key: "g"}, %{key_buffer: ["g"]}) do
  {:msg, :go_to_top}  # gg command
end

def event_to_msg(%Event.Key{key: key}, _state) when is_binary(key) do
  {:msg, {:key_pressed, key}}
end

def update({:key_pressed, key}, state) do
  buffer = [key | state.key_buffer] |> Enum.take(2)
  {%{state | key_buffer: buffer}, [Command.timer(500, :clear_buffer)]}
end

def update(:clear_buffer, state) do
  {%{state | key_buffer: []}, []}
end

Different handling for different modes:

def event_to_msg(event, %{mode: :normal} = state) do
  handle_normal_mode(event, state)
end

def event_to_msg(event, %{mode: :insert} = state) do
  handle_insert_mode(event, state)
end

defp handle_normal_mode(%Event.Key{key: "i"}, _state), do: {:msg, :enter_insert}
defp handle_normal_mode(%Event.Key{key: "j"}, _state), do: {:msg, :move_down}
defp handle_normal_mode(%Event.Key{key: "k"}, _state), do: {:msg, :move_up}
defp handle_normal_mode(_, _), do: :ignore

defp handle_insert_mode(%Event.Key{key: :escape}, _state), do: {:msg, :exit_insert}
defp handle_insert_mode(%Event.Key{char: char}, _state) when is_binary(char) do
  {:msg, {:insert_char, char}}
end
defp handle_insert_mode(_, _), do: :ignore

Event Constructors

Create events programmatically (useful for testing):

# Key events
Event.key(:enter)
Event.key("a")
Event.key("s", modifiers: [:ctrl])

# Mouse events
Event.mouse(:click, :left, 10, 5)
Event.mouse(:scroll_up, nil, 10, 5)

# Other events
Event.Resize.new(120, 40)
Event.Focus.new(:gained)
Event.Paste.new("text")

Testing Events

defmodule MyApp.ComponentTest do
  use ExUnit.Case
  alias TermUI.Event

  test "enter key submits form" do
    state = %{input: "test"}
    event = Event.key(:enter)

    assert {:msg, :submit} = MyApp.Component.event_to_msg(event, state)
  end

  test "ctrl+s saves" do
    event = Event.key("s", modifiers: [:ctrl])
    assert {:msg, :save} = MyApp.Component.event_to_msg(event, %{})
  end

  test "click selects item" do
    event = Event.mouse(:click, :left, 5, 10)
    assert {:msg, {:select, 5, 10}} = MyApp.Component.event_to_msg(event, %{})
  end
end

Next Steps