Drafter.Widget.ScrollableContainer (drafter v0.3.1)

Copy Markdown View Source

Tracks the scroll position of a viewport and renders its scrollbars.

This widget occupies only the one-column scrollbar strip at the right edge of the scrollable region and draws the track and thumb there. It does not render the content: children are laid out separately into the remaining width, and the container holds the offsets that position them.

The thumb position is a function of :content_height, :viewport_height and :scroll_offset_y. Keep all three current — update/2 accepts them and clamps the offsets to the content — or the thumb will not match what is on screen. get_viewport/1 returns the current scroll state for child rendering.

Keyboard and mouse-wheel events scroll the viewport when focused.

Component tag

This module has no component_tag/0 and is not reached through the widget registry. Drafter.App builds it as the element {:scrollable, children, opts}:

scrollable(children, opts)

children are the elements to scroll. The renderer measures them, sets :content_height from their combined preferred height and :viewport_height and :viewport_width from the allocated rect, and re-measures on every pass — so those three are computed for you and are not worth passing. A trailing {:footer, _} child is split off and rendered outside the scrolled region.

Options

  • :content_height - non_neg_integer/0 total content height in rows. Default 0; set from the children's measured height through the element. Live-updatable.
  • :content_width - non_neg_integer/0 total content width in columns. Default 0. Live-updatable; used only to clamp :scroll_offset_x.
  • :viewport_height - pos_integer/0 visible height in rows. Default 10; set from the allocated rect through the element. Live-updatable.
  • :viewport_width - pos_integer/0 visible width in columns. Default 80; set from the allocated rect less the scrollbar column through the element. Live-updatable.
  • :show_vertical_scrollbar - :auto | :always | :never. Default :auto. Only :never changes anything: the scrollbar is drawn whenever the content is taller than the viewport and suppressed otherwise, so :always behaves like :auto. Mount-only.

  • :show_horizontal_scrollbar - :auto | :always | :never. Default :never. Held on the state and never read; no horizontal scrollbar is drawn. Mount-only.

  • :click_to_scroll - boolean/0. Default false. Held on the state and only consulted together with the internal :scroll_locked flag, which nothing sets, so it has no effect on rendering. Clicking the track above or below the thumb always pages the viewport. Mount-only.
  • :focusable - boolean/0. Default true. Held on the state and never read; the container is always focusable through its :focusable trait. Mount-only.
  • :child_widget_ids - list of widget IDs whose scroll events bubble through this container. Default []. Live-updatable.
  • :id - identifier for the container. Default nil; set by the renderer through the element. Mount-only.
  • :focused - boolean/0 initial focus state. Default false. Mount-only.

update/2 additionally accepts :scroll_offset_y and :scroll_offset_x to drive the position directly; both are clamped to the content extent, as is any offset already on the state, on every call. mount/1 always starts both at 0 and ignores any offset in the props.

Key bindings

  • up / down — one row
  • page_up / page_down — one viewport height
  • home / end — top or bottom

The mouse wheel moves three rows. Pressing the thumb starts a drag; pressing the track above or below it pages the viewport on release.

Usage

scrollable([label("row 1"), label("row 2")], show_vertical_scrollbar: :always)

Summary

Functions

The scroll state the renderer needs to position the children.

Handles the container's own events, replacing the dispatch use Drafter.Widget would otherwise generate.

Builds the widget state from props.

Draws the one-column scrollbar into rect.

Callback implementation for Drafter.Widget.unmount/1.

Folds the measurements and offsets in props into state and re-clamps both scroll offsets.

Types

t()

@type t() :: %Drafter.Widget.ScrollableContainer{
  child_widget_ids: [term()],
  click_to_scroll: boolean(),
  content_height: non_neg_integer(),
  content_width: non_neg_integer(),
  drag_thumb_offset: non_neg_integer(),
  dragging_scrollbar: boolean(),
  focusable: boolean(),
  focused: boolean(),
  hovering_scrollbar: boolean(),
  id: term(),
  scroll_locked: boolean(),
  scroll_offset_x: non_neg_integer(),
  scroll_offset_y: non_neg_integer(),
  show_horizontal_scrollbar: :auto | :always | :never,
  show_vertical_scrollbar: :auto | :always | :never,
  viewport_height: pos_integer(),
  viewport_width: pos_integer()
}

viewport()

@type viewport() :: %{
  scroll_y: non_neg_integer(),
  scroll_x: non_neg_integer(),
  viewport_height: pos_integer(),
  viewport_width: pos_integer(),
  content_height: non_neg_integer(),
  content_width: non_neg_integer()
}

Functions

focused(state)

get_viewport(state)

@spec get_viewport(t()) :: viewport()

The scroll state the renderer needs to position the children.

Returns :scroll_y, :scroll_x, :viewport_height, :viewport_width, :content_height and :content_width.

iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> Drafter.Widget.ScrollableContainer.get_viewport(state)
%{scroll_y: 0, scroll_x: 0, viewport_height: 10, viewport_width: 80, content_height: 100, content_width: 0}

handle_event(event, state)

@spec handle_event(term(), t()) ::
  {:ok, t()} | {:ok, t(), list()} | {:bubble, t()} | {:noreply, t()}

Handles the container's own events, replacing the dispatch use Drafter.Widget would otherwise generate.

Recognised events:

  • {:key, :up} / {:key, :down} - one row
  • {:key, :page_up} / {:key, :page_down} - one viewport height
  • {:key, :home} / {:key, :end} - top or bottom
  • {:mouse, %{type: :scroll, direction: dir}} - three rows
  • {:mouse, %{type: :mouse_down, x: 0, y: y}} - start a thumb drag when y is on the thumb, otherwise {:noreply, state}; a press anywhere else bubbles
  • {:mouse, %{type: :drag, y: y}} while dragging - move the viewport to match the pointer
  • {:mouse, %{type: :mouse_up}} while dragging - end the drag
  • {:mouse, %{type: :mouse_up, x: 0, y: y}} - page up or down when y is off the thumb, otherwise bubble

Every other event returns {:bubble, state}. Anything that moves the viewport returns {:ok, state, [:scroll_fast_render]}, except the home and end keys, which return {:ok, state} and are not clamped against the content.

iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> {:ok, scrolled, actions} = Drafter.Widget.ScrollableContainer.handle_event({:key, :page_down}, state)
iex> {scrolled.scroll_offset_y, actions}
{10, [:scroll_fast_render]}

iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> {:ok, scrolled, _} = Drafter.Widget.ScrollableContainer.handle_event({:key, :up}, state)
iex> scrolled.scroll_offset_y
0

iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> {:ok, bottom} = Drafter.Widget.ScrollableContainer.handle_event({:key, :end}, state)
iex> bottom.scroll_offset_y
90

iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> Drafter.Widget.ScrollableContainer.handle_event({:key, :tab}, state) == {:bubble, state}
true

mount(props)

@spec mount(Drafter.Widget.props()) :: t()

Builds the widget state from props.

Both scroll offsets start at 0 whatever props says, and so do :dragging_scrollbar, :hovering_scrollbar, :drag_thumb_offset and the internal :scroll_locked flag.

iex> state = Drafter.Widget.ScrollableContainer.mount(%{})
iex> {state.content_height, state.viewport_height, state.viewport_width}
{0, 10, 80}

iex> state = Drafter.Widget.ScrollableContainer.mount(%{scroll_offset_y: 5})
iex> {state.scroll_offset_y, state.show_vertical_scrollbar, state.show_horizontal_scrollbar}
{0, :auto, :never}

preferred_height(args, opts)

render(state, rect)

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

Draws the one-column scrollbar into rect.

Returns [] — no scrollbar at all — when the content fits the viewport or :show_vertical_scrollbar is :never. Otherwise returns min(rect.height, viewport_height) single-cell strips, with the thumb sized as viewport_height * viewport_height / content_height, at least one row. The content itself is drawn by the children, not here.

unmount(state)

Callback implementation for Drafter.Widget.unmount/1.

update(props, state)

@spec update(Drafter.Widget.props(), t()) :: t()

Folds the measurements and offsets in props into state and re-clamps both scroll offsets.

Accepts :content_height, :content_width, :viewport_height, :viewport_width, :child_widget_ids, :scroll_offset_y and :scroll_offset_x, and drops every other key. :scroll_offset_y is clamped to 0..(content_height - viewport_height) and :scroll_offset_x to 0..(content_width - viewport_width), so shrinking the content pulls an out-of-range offset back into view.

iex> state = Drafter.Widget.ScrollableContainer.mount(%{})
iex> updated = Drafter.Widget.ScrollableContainer.update(%{content_height: 100, scroll_offset_y: 999}, state)
iex> updated.scroll_offset_y
90

iex> state = Drafter.Widget.ScrollableContainer.mount(%{content_height: 100})
iex> updated = Drafter.Widget.ScrollableContainer.update(%{scroll_offset_y: 50}, state)
iex> Drafter.Widget.ScrollableContainer.update(%{content_height: 20}, updated).scroll_offset_y
10

update_props_from_mount(mount_props, existing_state, opts)