Atui.Panes (Atui v0.1.0)

Copy Markdown View Source

A tiling window manager a view can hold in its own state.

Atui.Runtime stacks views: each push draws over what is below it, which is what a popup wants. Panes are the other arrangement — several views visible at once, side by side, sharing the space. A view that holds a %Atui.Panes{} becomes a container: it opens and closes windows, moves focus between them, rearranges them, and gives each one its own ticker, without any of them knowing they are not alone on the screen.

Panes hold ordinary Atui.View modules. A pane is either such a module or :self — the placeholder for content the host view draws itself, so the container is a window in its own layout rather than a frame around one.

Holding one

The host keeps the struct in its state and delegates the callbacks to it. into/3 rewrites a reply about the panes into a reply about the state that holds them, which is what makes the delegation one line long:

defmodule Dashboard do
  use Atui.View

  alias Atui.Panes

  def mount(_opts), do: {:ok, %{panes: Panes.new(host: __MODULE__)}}

  def handle_key({:char, "q"}, state), do: {:halt, state}

  def handle_key(key, state) do
    case state.panes |> Panes.handle_key(key) |> Panes.into(state) do
      {:pass, state} -> shortcut(state, key)
      reply -> reply
    end
  end

  def handle_event(event, state) do
    state.panes |> Panes.handle_event(event) |> Panes.into(state)
  end

  def render(state, rect) do
    Panes.render(state.panes, rect, fn tile -> banner(state, tile) end)
  end

  def unmount(state), do: Panes.unmount_all(state.panes)
end

host: is the host's own module. Panes address their timers back to it through Atui.Runtime.schedule_event_to/3, so handle_event/2 has to be delegated for a window's ticker to run at all.

Keys, and who gets them

handle_key/2 offers the key to the focused pane first and answers with whatever that pane replied — including {:pass, panes} for a key the pane did not claim, which is the host's cue to treat it as a shortcut of its own. A window is therefore free to bind a key the container also uses, and only what no window wants reaches the container.

Movement is not automatic: bind focus_next/1, move_focus/2 and swap/2 to whichever keys suit the application, and bind them before delegating so a window cannot swallow them.

Ticks

Runtime timers only cover views in its stack, so a pane's ticker is a scheduled event addressed back to the host. Each pane is asked for its Atui.View.tick_interval/1 when it opens and again after every tick, and counts its own ticks from one — the same contract a view in the stack gets.

Summary

Functions

Unmounts module's pane and takes it out of the layout.

How many panes there are.

Focuses the pane at index. nil or an index out of range changes nothing.

The index of the focused pane.

Moves focus to the next pane in layout order, wrapping at the end.

Moves focus to the previous pane in layout order, wrapping at the start.

The focused pane — :self, a module, or nil when there are none.

Handles an event on behalf of the panes.

Offers key to the focused pane.

Rewrites a reply about the panes into a reply about the state holding them.

The panes in layout order, as :self or a module.

Moves focus to the neighbouring pane in direction.

Builds a set of panes.

Mounts module as a new pane at the end of the layout, and focuses it.

Whether module currently has a pane.

Replaces the state of module's pane — how a host drives one directly.

Draws every pane into a screen the size of rect.

The state of module's pane, or nil if it is not open.

Swaps the focused pane with its neighbour in direction.

How many ticks module's pane has had.

How rect is divided between the panes, in layout order.

Closes module's pane if it is open, and opens it if it is not.

Unmounts every pane. Call it from the host's Atui.View.unmount/1.

Types

pane()

@type pane() :: :self | module()

t()

@type t() :: %Atui.Panes{
  focus: non_neg_integer(),
  focus_style: Atui.Style.t() | nil,
  host: module(),
  panes: [pane()],
  states: %{required(module()) => Atui.View.state()},
  ticks: %{required(module()) => non_neg_integer()}
}

Functions

close(panes, module)

Unmounts module's pane and takes it out of the layout.

Focus stays where it is unless the closed pane was the last one, in which case it moves back to what is now the end. Closing a module with no pane is a no-op.

count(panes)

How many panes there are.

focus(panes, index)

Focuses the pane at index. nil or an index out of range changes nothing.

focus_index(panes)

The index of the focused pane.

focus_next(panes)

Moves focus to the next pane in layout order, wrapping at the end.

focus_prev(panes)

Moves focus to the previous pane in layout order, wrapping at the start.

focused(panes)

The focused pane — :self, a module, or nil when there are none.

handle_event(panes, event)

Handles an event on behalf of the panes.

A pane's ticker comes through here, which is why the host has to delegate this callback. Anything else is broadcast to every pane, the way a container passes on news that is nobody's in particular — a job finishing, a subscription firing. Broadcast replies only update pane state; a pane cannot halt the application or close itself from an event it shares with its siblings.

handle_key(panes, key)

Offers key to the focused pane.

Returns the pane's own reply, with the panes as its state:

  • {:ok, panes} / {:pass, panes} — the pane's new state is kept, and a pass is still a pass, so the host can treat the key as a shortcut
  • {:push, module, opts, panes} — passed along; a pushed view lands on the runtime's stack, over the whole layout
  • {:pop, _} from a pane closes it, and comes back as {:ok, panes}
  • {:halt, panes} — passed along, quitting the application

With focus on :self there is no pane to ask, so the reply is {:pass, panes}.

into(reply, state, key \\ :panes)

Rewrites a reply about the panes into a reply about the state holding them.

key is where the struct lives in that state, :panes by default.

state.panes |> Panes.handle_event(event) |> Panes.into(state)

list(panes)

The panes in layout order, as :self or a module.

move_focus(panes, direction)

Moves focus to the neighbouring pane in direction.

direction is :left, :right, :up or :down. Focus stays put at the edge of the grid — see Atui.Layout.neighbour/3 for how the grid is shaped.

new(opts \\ [])

Builds a set of panes.

Options:

  • :host — the module holding these panes (required). Pane timers are delivered to it as events, so it has to delegate handle_event/2.
  • :panes — what to start with, in order: :self, a view module, or {module, mount_opts}. Defaults to [:self].
  • :focus_style — the Atui.Style the focused pane's border is recoloured with. nil draws no focus ring, for a container that marks focus itself.

open(panes, module, mount_opts \\ [])

Mounts module as a new pane at the end of the layout, and focuses it.

mount_opts is passed to the view's Atui.View.mount/1. Opening a module that already has a pane simply focuses it, so a shortcut cannot mount the same window twice.

open?(panes, module)

Whether module currently has a pane.

put_state(panes, module, view_state)

Replaces the state of module's pane — how a host drives one directly.

render(panes, rect, self_fun \\ &blank/1)

Draws every pane into a screen the size of rect.

self_fun is called with the local rect of the :self pane and returns the screen to put there — the host's own content, drawn as a window among the others. It is not called when there is no such pane.

Each pane renders in local coordinates, unaware of where in the layout it landed, and the focused pane's border is recoloured afterwards, so a container can mark one of its children without knowing how that child framed itself.

state(panes, module)

The state of module's pane, or nil if it is not open.

swap(panes, direction)

Swaps the focused pane with its neighbour in direction.

This is what rearranges the layout: two panes trade places and focus travels with the one that moved, so a pane can be walked across the grid one swap at a time. Nothing happens at the edge.

ticks(panes, module)

How many ticks module's pane has had.

tiles(panes, rect)

How rect is divided between the panes, in layout order.

The same rects render/3 draws into — for a host that needs to know where a pane sits, to place a cursor or to read a mouse position.

toggle(panes, module, mount_opts \\ [])

Closes module's pane if it is open, and opens it if it is not.

unmount_all(panes)

Unmounts every pane. Call it from the host's Atui.View.unmount/1.