TermUI.Widgets.ContextMenu.Behavior (TermUI v1.0.0)

View Source

Shared behavior for context menu variants.

This module provides common functionality for both positioned (ContextMenu) and inline (ContextMenu.Inline) menu implementations. It extracts shared logic for item selection, cursor management, and menu actions to eliminate code duplication and ensure consistent behavior across menu types.

This is not a formal Elixir @behaviour but rather a collection of utility functions used by multiple menu implementations.

Shared Functionality

  • Item Selection: Determining which items can be selected
  • Cursor Management: Moving cursor between selectable items
  • Menu Actions: Selecting items and closing menus

Usage

Menu implementations should alias this module and delegate to its functions:

alias TermUI.Widgets.ContextMenu.Behavior

def init(props) do
  state = %{
    items: props.items,
    cursor: Behavior.find_first_selectable(props.items),
    # ...
  }
  {:ok, state}
end

def handle_event(%Event.Key{key: :down}, state) do
  state = Behavior.move_cursor(state, 1)
  {:ok, state}
end

Summary

Functions

Closes the menu and invokes the on_close callback.

Finds the ID of the first selectable item in a list.

Moves the cursor in the specified direction among selectable items.

Selects the item at the current cursor position.

Returns whether an item can be selected.

Functions

close_menu(state)

@spec close_menu(map()) :: map()

Closes the menu and invokes the on_close callback.

Sets the :visible state to false and calls the on_close callback if provided. This is typically called when the menu is dismissed via escape key or click outside.

Parameters

  • state - State map containing:
    • :on_close - Callback function fn -> ... end (optional)
    • :visible - Current visibility state

Returns

Updated state with :visible set to false.

Callback Execution

The on_close callback is executed synchronously before setting visibility. Callback exceptions are rescued and logged.

Examples

state = %{
  on_close: fn -> IO.puts("Menu closed") end,
  visible: true
}

new_state = Behavior.close_menu(state)
# Prints: "Menu closed"
# new_state.visible == false

find_first_selectable(items)

@spec find_first_selectable([map()]) :: term() | nil

Finds the ID of the first selectable item in a list.

Returns nil if no selectable items exist. This is useful for initializing the cursor position to the first available action.

Examples

iex> items = [
...>   %{type: :separator},
...>   %{type: :action, id: :copy, disabled: false},
...>   %{type: :action, id: :paste, disabled: false}
...> ]
iex> Behavior.find_first_selectable(items)
:copy

iex> Behavior.find_first_selectable([%{type: :separator}])
nil

move_cursor(state, direction)

@spec move_cursor(map(), integer()) :: map()

Moves the cursor in the specified direction among selectable items.

Direction is +1 for next item, -1 for previous item. Movement is clamped at boundaries (does not wrap around). Non-selectable items (separators, disabled actions) are automatically skipped.

Parameters

  • state - State map containing :items and :cursor keys
  • direction - Integer offset: -1 for previous, +1 for next

Returns

Updated state with new cursor position. If cursor is at a boundary and movement would go beyond it, cursor remains unchanged.

Examples

state = %{
  items: [
    %{type: :action, id: :copy},
    %{type: :action, id: :paste}
  ],
  cursor: :copy
}

# Move to next item
new_state = Behavior.move_cursor(state, 1)
# new_state.cursor == :paste

# At boundary, cursor stays in place
new_state = Behavior.move_cursor(new_state, 1)
# new_state.cursor == :paste (unchanged)

select_at_cursor(state)

@spec select_at_cursor(map()) :: map()

Selects the item at the current cursor position.

Invokes the on_select callback if the item is selectable (action type and not disabled), then closes the menu. If the cursor is on a non-selectable item, no action is taken.

Parameters

  • state - State map containing:
    • :items - List of menu items
    • :cursor - ID of currently focused item
    • :on_select - Callback function fn id -> ... end (optional)

Returns

Updated state with menu closed (:visible set to false).

Callback Execution

The on_select callback is executed synchronously. Callback exceptions are rescued, logged, and returned internally as errors without crashing the caller.

Examples

state = %{
  items: [%{type: :action, id: :copy, disabled: false}],
  cursor: :copy,
  on_select: fn id -> IO.puts("Selected: #{id}") end,
  visible: true
}

new_state = Behavior.select_at_cursor(state)
# Prints: "Selected: copy"
# new_state.visible == false

selectable?(item)

@spec selectable?(map()) :: boolean()

Returns whether an item can be selected.

An item is selectable if it's an action type and not disabled. Separators and disabled action items are not selectable.

Examples

iex> Behavior.selectable?(%{type: :action, disabled: false})
true

iex> Behavior.selectable?(%{type: :action, disabled: true})
false

iex> Behavior.selectable?(%{type: :separator})
false