Drafter.Widget behaviour (drafter v0.3.1)

Copy Markdown View Source

Behaviour for a Drafter widget: a component that owns state, draws itself into a rect, and handles events.

A widget module does use Drafter.Widget, opts and implements at least mount/1 and render/2. Everything else is optional and has a default.

defmodule MyWidgets.Counter do
  use Drafter.Widget, handles: [:keyboard, :press], focusable: true

  alias Drafter.Draw.{Segment, Strip}

  defstruct count: 0, focused: false

  def component_tag, do: :counter
  def from_component_opts(_args, opts), do: %{count: Keyword.get(opts, :count, 0)}

  @impl true
  def mount(props), do: %__MODULE__{count: Map.get(props, :count, 0)}

  @impl true
  def render(%__MODULE__{count: count}, _rect) do
    [Strip.new([Segment.new("count: #{count}", %{})])]
  end

  def handle_key(:up, state), do: {:ok, %{state | count: state.count + 1}}
  def handle_key(_key, state), do: {:bubble, state}

  def handle_press(_x, _y, state), do: {:ok, %{state | count: state.count + 1}}
end

component_tag/0 and from_component_opts/2 are what let the widget appear in a component tree as {:counter, count: 3}; they belong to the widget registry, not to this behaviour. Without them a widget is still usable as {MyWidgets.Counter, %{count: 3}}.

use Drafter.Widget options

Handles mode — the default, chosen whenever :traits is absent or empty:

  • :handles - the event kinds the widget wants, as a list of atoms. Default []. :keyboard, :char, :paste, :press (:click is accepted for it), :mouse_up, :drag, :hover, :scroll. An event kind that is not declared is never routed to the widget, whatever callbacks it exports.
  • :focusable - boolean(). Default :keyboard in handles. A focusable widget takes part in tab order and gets :focused set on its state by {:focus} and {:blur}.
  • :capture_handles - event kinds the widget sees during the capture phase, before they reach their target. Default []; requires handle_event_capture/2.
  • :scroll - keyword list configuring default scrolling: :direction (:horizontal default), :step (5 default), :wrap (false default). Default nil, which becomes %{direction: :horizontal, step: 5} when :scroll is in :handles.
  • :layout_impact - how far a state change invalidates layout: :self (default), :below, :above, :left, :right, :parent, or :all.

Trait mode — chosen when :traits is a non-empty list:

  • :traits - trait specs resolved by Drafter.Widget.Trait, e.g. [:focusable, :scrollable]. The traits supply the handles, focusability, default state, and the handle_event/2 pipeline.
  • :handles - extra event kinds merged with the ones the traits declare.
  • :scroll - passed to the traits as Drafter.Widget.Trait.scroll_config/2 reads it.
  • :layout_impact - as above.

Trait mode reads no :focusable and no :capture_handles: focusability comes from the traits and the generated __widget_capabilities__/0 reports capture_handles: [].

Both modes generate handle_event/2, __widget_capabilities__/0, and __layout_impact__/0, and overridable defaults for mount/1, render/2, update/2, unmount/1, focused/1, update_props_from_mount/3, and preferred_height/2. Trait mode additionally generates __widget_traits__/0, __widget_capabilities_bitmap__/0, __render_affecting_fields__/0, __layout_static__/0, and __trait_default_state__/0.

Event callbacks and their results

Drafter.Widget.EventRouter turns each event into the matching callback, and only when the kind is declared and the function is exported. Every event callback returns one of:

  • {:ok, new_state} - handled. The event stops here and does not reach the parent.
  • {:ok, new_state, actions} - handled, plus a list of actions for the app; see Drafter.EventResult.
  • {:bubble, new_state} / {:bubble, new_state, actions} - the state is kept and the event continues to the parent widget, and then to the app.
  • {:noreply, state} - treated as not handled: the event continues and the returned state is discarded. Return {:bubble, new_state} to keep a change while still letting the event through.
  • an action tuple — {:pop, result}, {:push, module, props}, {:replace, module, props}, {:app_callback, name, data} - the action is recorded, the event stops, and the widget's state is left as it was before the event.

Anything else counts as not handled and the state is left unchanged.

Paste

A widget receives pasted text only if it declares :paste:

use Drafter.Widget, handles: [:keyboard, :paste], focusable: true

def handle_paste(text, state), do: {:ok, %{state | value: state.value <> text}}

text has already been through Drafter.Clipboard.sanitize/1 and carries no escape sequences. A widget that does not declare :paste bubbles the event instead. Drafter.Clipboard.copy/1 is the other direction.

Summary

Types

An action a widget hands back to the app or the screen stack.

What an event callback may return.

Callbacks

Folds the items accumulated in the widget's data channel into its state.

Handles any event with no callback of its own — {:timer, id}, {:custom, term}, and {:char, codepoint} when :char is not declared. Needs no entry in :handles.

Handles mouse motion with a button held. Requires :drag in :handles. x and y are absolute zero-based screen cells.

Handles an event.

Inspects an event on its way down to its target, before the target sees it. Requires the event's kind in :capture_handles.

Handles mouse motion with no button held. Requires :hover in :handles, and the app to have been started with hover tracking on.

Handles a key press with no modifiers. Requires :keyboard in :handles.

Handles a key press with modifiers. Requires :keyboard in :handles.

Handles a mouse button release, the event that activates a widget. Requires :mouse_up in :handles. x and y are absolute zero-based screen cells.

Handles pasted text. Requires :paste in :handles.

Handles a mouse button press. Requires :press (or its alias :click) in :handles. x and y are absolute zero-based screen cells.

Handles a scroll wheel event. Requires :scroll in :handles.

Whether the widget is drawing a transmitted image right now.

Builds the widget's initial state from its props map.

Draws the widget into rect.

Releases the widget's resources as it leaves the hierarchy. Returns :ok.

Folds a fresh props map into an existing state when the widget is re-rendered.

Functions

Applies re-render props to a widget's state, using that widget's own update/2.

Default handle_event/2: handles nothing, returning {:noreply, state}.

Whether module draws a transmitted image for state.

Default mount/1: ignores the props and returns %{}.

The event names a handles: list resolves to.

Default render/2: draws nothing, returning [].

Default unmount/1: returns :ok.

Default update/2: ignores the props and returns state unchanged.

Types

action()

@type action() ::
  {:pop, term()}
  | {:push, module(), props()}
  | {:replace, module(), props()}
  | {:app_callback, atom(), term()}

An action a widget hands back to the app or the screen stack.

Drafter.EventResult.parse/2 recognises exactly these four shapes, records them and stops the event. {:app_callback, name, data} is the one the app itself handles, through handle_event/3.

event_result()

@type event_result() ::
  {:ok, state()}
  | {:ok, state(), [action()]}
  | {:bubble, state()}
  | {:bubble, state(), [action()]}
  | {:noreply, state()}
  | {:error, term()}
  | action()

What an event callback may return.

Drafter.EventResult.parse/2 maps these onto {state, actions, mode}; anything else, including {:error, reason}, counts as not handled and leaves the state as it was.

expand_option()

@type expand_option() :: :fill | :content | pos_integer()

key()

@type key() :: atom()

layout_impact()

@type layout_impact() :: :self | :below | :above | :left | :right | :all | :parent

modifiers()

@type modifiers() :: [:ctrl | :alt | :shift | :meta]

props()

@type props() :: map()

rect()

@type rect() :: %{
  x: non_neg_integer(),
  y: non_neg_integer(),
  width: pos_integer(),
  height: pos_integer()
}

render_result()

@type render_result() :: [Drafter.Draw.Strip.t()] | {:error, term()}

scroll_direction()

@type scroll_direction() :: :up | :down

state()

@type state() :: term()

Callbacks

apply_data_buffer(state, t, rect)

(optional)
@callback apply_data_buffer(state(), Drafter.RingBuffer.t(), rect()) :: state()

Folds the items accumulated in the widget's data channel into its state.

Called when the throttle window of a widget declared with :buffer and :refresh opens. buffer is the Drafter.RingBuffer holding everything pushed since the last call; rect is the widget's current rect, so a widget can keep only as many points as it can draw. Returns the new state.

handle_custom_event(t, state)

(optional)
@callback handle_custom_event(Drafter.Event.t(), state()) :: event_result()

Handles any event with no callback of its own — {:timer, id}, {:custom, term}, and {:char, codepoint} when :char is not declared. Needs no entry in :handles.

handle_drag(x, y, state)

(optional)
@callback handle_drag(x :: integer(), y :: integer(), state()) :: event_result()

Handles mouse motion with a button held. Requires :drag in :handles. x and y are absolute zero-based screen cells.

handle_event(t, state)

@callback handle_event(Drafter.Event.t(), state()) :: event_result()

Handles an event.

Generated by use Drafter.Widget and rarely written by hand: the generated version dispatches to handle_key/2, handle_press/3, and the rest according to the declared handles. Overriding it replaces that dispatch entirely.

handle_event_capture(t, state)

(optional)
@callback handle_event_capture(Drafter.Event.Object.t(), state()) ::
  {:continue, Drafter.Event.Object.t(), state()}
  | {:stop, Drafter.Event.Object.t(), state(), list()}
  | {:prevent, Drafter.Event.Object.t(), state()}

Inspects an event on its way down to its target, before the target sees it. Requires the event's kind in :capture_handles.

Returns {:continue, event, state} to let it carry on to the target, {:prevent, event, state} to suppress the target's default behaviour, or {:stop, event, state, actions} to end the dispatch here and emit actions.

handle_hover(x, y, state)

(optional)
@callback handle_hover(x :: integer(), y :: integer(), state()) :: event_result()

Handles mouse motion with no button held. Requires :hover in :handles, and the app to have been started with hover tracking on.

handle_key(key, state)

(optional)
@callback handle_key(key(), state()) :: event_result()

Handles a key press with no modifiers. Requires :keyboard in :handles.

key is the key atom — :enter, :up, or a printable ASCII character as an atom. Also receives {:key, key, []} events when handle_key/3 is not exported.

handle_key(key, modifiers, state)

(optional)
@callback handle_key(key(), modifiers(), state()) :: event_result()

Handles a key press with modifiers. Requires :keyboard in :handles.

modifiers is a subset of [:ctrl, :alt, :shift] in that order. Takes precedence over handle_key/2 for every {:key, key, modifiers} event, including one with an empty modifier list.

handle_mouse_up(x, y, state)

(optional)
@callback handle_mouse_up(x :: integer(), y :: integer(), state()) :: event_result()

Handles a mouse button release, the event that activates a widget. Requires :mouse_up in :handles. x and y are absolute zero-based screen cells.

handle_paste(text, state)

(optional)
@callback handle_paste(text :: String.t(), state()) :: event_result()

Handles pasted text. Requires :paste in :handles.

text has already passed through Drafter.Clipboard.sanitize/1.

handle_press(x, y, state)

(optional)
@callback handle_press(x :: integer(), y :: integer(), state()) :: event_result()

Handles a mouse button press. Requires :press (or its alias :click) in :handles. x and y are absolute zero-based screen cells.

handle_scroll(scroll_direction, state)

(optional)
@callback handle_scroll(scroll_direction(), state()) :: event_result()

Handles a scroll wheel event. Requires :scroll in :handles.

direction is :up or :down. When :scroll is declared but this is not exported, the router moves :_scroll_offset on the state by the scroll config's :step instead.

image_active?(state)

(optional)
@callback image_active?(state()) :: boolean()

Whether the widget is drawing a transmitted image right now.

A widget that exports image/3 but only paints in some of its modes implements this so the runtime can skip the image pipeline entirely in the others: no per-frame placement, no generation task, no bytes. Absent, a widget exporting image/3 counts as always active.

Called on the current state on every frame, so it must be cheap.

mount(props)

@callback mount(props()) :: state()

Builds the widget's initial state from its props map.

props comes from from_component_opts/2 for a tag element, or is the map given directly in a {Module, props} element. The returned term is the widget's state, usually its own struct.

render(state, rect)

@callback render(state(), rect()) :: render_result()

Draws the widget into rect.

rect is %{x: , y: , width: , height: } in absolute screen cells. Returns a list of Drafter.Draw.Strip structs, one per row starting at rect.y, or {:error, reason}. Called on every frame the widget is visible, so it must be free of side effects.

unmount(state)

(optional)
@callback unmount(state()) :: :ok

Releases the widget's resources as it leaves the hierarchy. Returns :ok.

update(props, state)

(optional)
@callback update(props(), state()) :: state()

Folds a fresh props map into an existing state when the widget is re-rendered.

Returns the state to keep. A widget that defines no update/2 keeps its state unchanged, so state a widget owns and props do not describe — scroll offset, cursor position, drag state — survives a re-render.

Functions

apply_props(module, props, state)

@spec apply_props(module(), map(), state()) :: state()

Applies re-render props to a widget's state, using that widget's own update/2.

module is the widget module, props the map from the new render, state the state as it stands. Returns the state to keep. A module that exports no update/2 gets state back unchanged, so state the props do not describe — scroll offset, cursor and drag positions — survives.

Both the in-hierarchy path and the widget's process go through this, so a widget behaves the same whether or not it runs in its own process.

handle_event(event, state)

@spec handle_event(Drafter.Event.t(), state()) :: event_result()

Default handle_event/2: handles nothing, returning {:noreply, state}.

iex> Drafter.Widget.handle_event({:key, :up}, %{count: 0})
{:noreply, %{count: 0}}

image_active?(module, state)

@spec image_active?(module(), state()) :: boolean()

Whether module draws a transmitted image for state.

True only for a module exporting image/3, and then only when its image_active?/1 says so; a module without that callback is active whenever it exports image/3. The runtime asks this before placing an image or asking one to be generated, so a widget in a character-drawing mode costs nothing on the image path.

iex> Drafter.Widget.image_active?(Drafter.Widget.Label, %{})
false

iex> Drafter.Widget.image_active?(Drafter.Widget.Slider, Drafter.Widget.Slider.mount(%{renderer: :text}))
false

mount(props)

@spec mount(props()) :: state()

Default mount/1: ignores the props and returns %{}.

iex> Drafter.Widget.mount(%{count: 3})
%{}

normalize_handles(handles)

@spec normalize_handles([atom()]) :: [atom()]
@spec normalize_handles(term()) :: term()

The event names a handles: list resolves to.

Maps :click to :press, the name Drafter.Widget.EventRouter dispatches on, and removes duplicates. Every other atom is returned as given. A non-list argument is returned unchanged.

iex> Drafter.Widget.normalize_handles([:click, :press, :keyboard])
[:press, :keyboard]

iex> Drafter.Widget.normalize_handles([:keyboard, :scroll])
[:keyboard, :scroll]

iex> Drafter.Widget.normalize_handles(nil)
nil

render(state, rect)

@spec render(state(), rect()) :: render_result()

Default render/2: draws nothing, returning [].

iex> Drafter.Widget.render(%{}, %{x: 0, y: 0, width: 10, height: 1})
[]

unmount(state)

@spec unmount(state()) :: :ok

Default unmount/1: returns :ok.

iex> Drafter.Widget.unmount(%{count: 0})
:ok

update(props, state)

@spec update(props(), state()) :: state()

Default update/2: ignores the props and returns state unchanged.

iex> Drafter.Widget.update(%{count: 9}, %{count: 0})
%{count: 0}