Drafter.Widget.SplitPaneDivider (drafter v0.3.2)

Copy Markdown View Source

Renders a resizable divider between two panes in a split layout.

The divider position starts as a ratio (0.0–1.0) for the initial draw and after screen resize. Once the user drags the divider, it switches to an absolute pixel position so that resizing an outer split pane does not affect this divider.

Once :fixed_pos is set it takes precedence over :ratio; a parent that wants to reposition the divider from the outside must clear it.

Component tag

This module has no component_tag/0 and no Drafter.App helper of its own. The renderer creates one divider per gap in a {:split_pane, children, opts} element, built by Drafter.App.split_pane/2.

Options

  • :id - identifier for the divider. Default nil; set by the renderer. Mount-only.
  • :ratio - float/0 in 0.0..1.0 giving the initial split point. Default 0.5. Mount-only — update/2 never changes it.
  • :orientation - :horizontal (side-by-side panes) or :vertical (stacked panes). Default :horizontal. Live-updatable. Any other value raises a CaseClauseError from render/2.
  • :total_size - pos_integer/0 size in cells of the axis being split. Default 100. Live-updatable; changing it re-clamps a :fixed_pos that has already been set.
  • :show_handle - boolean/0, draw the grip marker in the middle of the divider. Default true. Live-updatable. The marker is only drawn while the divider is focused.
  • :resize_mode - :quick | :live. Default :quick, which emits {:divider_move, :quick} on each drag step and {:widget_layout_needed, :all} on mouse up. :live emits {:widget_layout_needed, :all} on every drag step and nothing on mouse up. Mount-only.

Key bindings

Arrow keys move the divider by one cell, and only with alt or shift held: left/right for a :horizontal divider, up/down for a :vertical one. Every other key bubbles.

Position clamping

Both dragging and nudging clamp the position to max(1, round(total_size * 0.1))..min(total_size - 2, round(total_size * 0.9)), so with the default :total_size of 100 the divider stays within columns 10 through 90.

State fields (read via WidgetHierarchy.get_widget_state/2)

  • :ratio - float 0.0–1.0; used for initial layout and screen resize fallback
  • :fixed_pos - integer column/row offset from start of parent rect; nil until the first drag or nudge, after which it overrides :ratio
  • :orientation - :horizontal (side-by-side) or :vertical (top-bottom)
  • :dragging - true between a press and the matching mouse up
  • :drag_start_pos - the position at the moment of the press; nil otherwise

Summary

Functions

The divider's current offset in cells from the start of the parent rect.

Moves the divider by the drag delta and clamps it.

Bubbles every unmodified key press. Resizing needs alt or shift; see handle_key/3.

Nudges the divider one cell when alt or shift is held and the arrow key matches the orientation.

Ends a drag by clearing :dragging. The coordinates are ignored.

Starts a drag: sets :dragging and records :drag_start_pos as the current effective position. The press coordinates are ignored.

Builds the widget state from props.

Draws the divider into rect, using the current theme's :primary colour while focused and :text_muted otherwise.

Callback implementation for Drafter.Widget.unmount/1.

Folds :orientation, :total_size and :show_handle into state.

Types

t()

@type t() :: %Drafter.Widget.SplitPaneDivider{
  drag_start_pos: integer() | nil,
  dragging: boolean(),
  fixed_pos: integer() | nil,
  focused: boolean(),
  id: term(),
  orientation: :horizontal | :vertical,
  ratio: float(),
  resize_mode: :quick | :live,
  show_handle: boolean(),
  total_size: pos_integer()
}

Functions

effective_pos(map)

@spec effective_pos(t()) :: integer()

The divider's current offset in cells from the start of the parent rect.

Returns :fixed_pos when it is an integer, and round(ratio * (total_size - 1)) otherwise. Not clamped — an out-of-range :fixed_pos is returned as it stands.

iex> Drafter.Widget.SplitPaneDivider.effective_pos(Drafter.Widget.SplitPaneDivider.mount(%{}))
50

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{ratio: 0.25, total_size: 41})
iex> Drafter.Widget.SplitPaneDivider.effective_pos(state)
10

iex> state = %{Drafter.Widget.SplitPaneDivider.mount(%{}) | fixed_pos: 7}
iex> Drafter.Widget.SplitPaneDivider.effective_pos(state)
7

focused(state)

handle_drag(x, y, state)

@spec handle_drag(integer(), integer(), t()) :: {:ok, t(), list()}

Moves the divider by the drag delta and clamps it.

x is used for a :horizontal divider and y for a :vertical one; the other coordinate is ignored. Both are deltas, not absolute positions. Returns {:ok, state, [{:widget_layout_needed, :all}]} in :live resize mode and {:ok, state, [{:divider_move, :quick}]} otherwise.

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> {:ok, dragged, actions} = Drafter.Widget.SplitPaneDivider.handle_drag(5, 0, state)
iex> {dragged.fixed_pos, actions}
{55, [{:divider_move, :quick}]}

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{resize_mode: :live})
iex> {:ok, _dragged, actions} = Drafter.Widget.SplitPaneDivider.handle_drag(5, 0, state)
iex> actions
[{:widget_layout_needed, :all}]

handle_event(event, state)

Callback implementation for Drafter.Widget.handle_event/2.

handle_key(key, state)

@spec handle_key(term(), t()) :: {:bubble, t()}

Bubbles every unmodified key press. Resizing needs alt or shift; see handle_key/3.

handle_key(direction, mods, state)

@spec handle_key(term(), Drafter.Widget.modifiers(), t()) ::
  {:ok, t()} | {:bubble, t()}

Nudges the divider one cell when alt or shift is held and the arrow key matches the orientation.

Returns {:ok, state} with :fixed_pos moved and clamped, or {:bubble, state} when no modifier is held or the key does not match the axis.

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> {:ok, moved} = Drafter.Widget.SplitPaneDivider.handle_key(:left, [:alt], state)
iex> moved.fixed_pos
49

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> Drafter.Widget.SplitPaneDivider.handle_key(:left, [], state) == {:bubble, state}
true

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> Drafter.Widget.SplitPaneDivider.handle_key(:up, [:alt], state) == {:bubble, state}
true

handle_mouse_up(x, y, state)

@spec handle_mouse_up(integer(), integer(), t()) :: {:ok, t()} | {:ok, t(), list()}

Ends a drag by clearing :dragging. The coordinates are ignored.

In :live resize mode returns {:ok, state} and keeps :drag_start_pos, because the layout was already refreshed on every drag step. In :quick mode it also clears :drag_start_pos and returns {:ok, state, [{:widget_layout_needed, :all}]}.

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> {:ok, released, actions} = Drafter.Widget.SplitPaneDivider.handle_mouse_up(0, 0, state)
iex> {released.dragging, actions}
{false, [{:widget_layout_needed, :all}]}

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{resize_mode: :live})
iex> {:ok, released} = Drafter.Widget.SplitPaneDivider.handle_mouse_up(0, 0, state)
iex> released.dragging
false

handle_press(x, y, state)

@spec handle_press(integer(), integer(), t()) :: {:ok, t()}

Starts a drag: sets :dragging and records :drag_start_pos as the current effective position. The press coordinates are ignored.

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> {:ok, pressed} = Drafter.Widget.SplitPaneDivider.handle_press(0, 0, state)
iex> {pressed.dragging, pressed.drag_start_pos}
{true, 50}

mount(props)

@spec mount(map()) :: t()

Builds the widget state from props.

Reads :id (default nil), :ratio (default 0.5), :orientation (default :horizontal), :total_size (default 100), :show_handle (default true) and :resize_mode (default :quick). :fixed_pos always starts as nil and :focused and :dragging as false, whatever props says.

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> {state.ratio, state.orientation, state.total_size, state.resize_mode}
{0.5, :horizontal, 100, :quick}

iex> Drafter.Widget.SplitPaneDivider.mount(%{fixed_pos: 40}).fixed_pos
nil

preferred_height(args, opts)

render(state, rect)

@spec render(t(), Drafter.Widget.rect()) :: [Drafter.Draw.Strip.t()]

Draws the divider into rect, using the current theme's :primary colour while focused and :text_muted otherwise.

A :horizontal divider is a one-column vertical line of rect.height strips; a :vertical divider is one strip rect.width wide. The grip marker replaces the middle character only while the divider is focused and :show_handle is set.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@spec update(map(), t()) :: t()

Folds :orientation, :total_size and :show_handle into state.

:id, :ratio and :resize_mode are ignored, so they are mount-only. A :fixed_pos that has already been set is re-clamped when :total_size changes and otherwise kept, so a parent that wants to reposition the divider from the outside has to clear :fixed_pos itself.

iex> state = Drafter.Widget.SplitPaneDivider.mount(%{})
iex> Drafter.Widget.SplitPaneDivider.update(%{ratio: 0.9, show_handle: false}, state)
...> |> then(&{&1.ratio, &1.show_handle})
{0.5, false}

iex> state = %{Drafter.Widget.SplitPaneDivider.mount(%{}) | fixed_pos: 90}
iex> Drafter.Widget.SplitPaneDivider.update(%{total_size: 50}, state).fixed_pos
45

update_props_from_mount(mount_props, existing_state, opts)