Scenic v0.10.2 Scenic.Scene behaviour View Source

Overview

Scenes are the core of the UI model.

A Scene has three jobs.

  1. Maintain any state specific to that part of your application.
  2. Build and maintain a graph of UI primitives that gets drawn to the screen.
  3. Handle input and events.

A brief aside

Before saying anything else I want to emphasize that the rest of your application, meaning device control logic, sensor reading / writing, services, whatever, does not need to have anything to do with Scenes.

In many cases I recommend treating those as separate GenServers in their own supervision trees that you maintain. Then your Scenes would query or send information to/from them via cast or call messages.

Part of the point of using Erlang/Elixir/OTP is separating this sort of logic into independent trees. That way, an error in one part of your application does not mean the rest of it will fail.

Scenes

Scenes are the core of the UI model. A scene consists of one ore more graphs, and a set of event handlers and filters to deal with user input and other messages.

Think of a scene as being a little like an HTML page. HTML pages have:

  • Structure (the DOM)
  • Logic (Javascript)
  • Links to other pages.

A Scene has:

  • Structure (graphs),
  • Logic (event handlers and filters)
  • Transitions to other scenes. Well... it can request the ViewPort to go to a different scene.

Your application is a collection of scenes that are in use at various times. There is only ever one scene showing in a ViewPort at a given time. However, scenes can reference other scenes, effectively embedding their graphs inside the main one. More on that below.

Graphs

Each scene should maintain at least one graph. You can build graphs at compile time, or dynamically while your scene is running. Building them at compile time has two advantages

  1. Performance: It is clearly faster to build the graph once during build time than to build it repeatedly during runtime.
  2. Error checking: If your graph has an error in it, it is much better to have it stop compilation than cause an error during runtime.

Example of building a graph during compile time:

@graph graph.build(font_size: 24) |> button({"Press Me", :button_id}, translate: {20, 20})

Rather than having a single scene maintain a massive graph of UI, graphs can reference graphs in other scenes.

On a typical screen of UI, there is one scene that is the root. Each control, is its own scene process with its own state. These child scenes can in turn contain other child scenes. This allows for strong code reuse, isolates knowledge and logic to just the pieces that need it, and keeps the size of any given graph to a reasonable size. For example, The handlers of a check-box scene don't need to know anything about how a slider works, even though they are both used in the same parent scene. At best, they only need to know that they both conform to the Component.Input behavior, and can thus query or set each others value. Though it is usually the parent scene that does that.

The application developer is responsible for building and maintaining the scene's graph. It only enters the world of the ViewPort when you call push_graph. Once you have called push_graph, that graph is sent to the drivers and is out of your immediate control. You update that graph by calling push_graph again.

This does mean you could maintain two separate graphs and rapidly switch back and forth between them via push_graph. I have not yet hit a good use case for that.

Graph ID.

This is an advanced feature...

Each scene has a default graph id of nil. When you send a graph to the ViewPort by calling push_graph(graph), you are really sending it with a sub-id of nil. This encourages thinking that scenes and graphs have a 1:1 relationship. There are, however, times that you want a single scene to host multiple graphs that can refer to each other via sub-ids. You would push them like this: push_graph(graph, id). Where the id is any term you would like to use as a key.

There are several use cases where this makes sense. 1) You have a complex tree (perhaps a lot of text) that you want to animate. Rather than re-rendering the text (relatively expensive) every time you simply transform a rotation matrix, you could place the text into its own static sub-graph and then refer to it from the primary. This will save energy as you animate.

2) Both the Remote and Recording clients make heavy use of sub-ids to make sense of the graph being replayed.

The downside of using graph_ids comes with input handling. because you have a single scene handling the input events for multiple graphs, you will need to take extra care to correctly handle position-dependent events, which may not be projected into the coordinate space you think they are. The Remote and Recording clients deal with this by rendering a single, completely transparent rect above all the sub scenes. This invisible, yet present rect hits all the position dependent events and makes sure they are sent to the scene projected in to the main (id is nil) graph's coordinate space.

Communications

Scenes are specialized GenServers. As such, they communicate with each other (and the rest of your application) through messages. You can receive messages with the standard handle_info, handle_cast, handle_call callbacks just like any other scene.

Scenes have two new event handling callbacks that you can optionally implement. These are about user input vs UI events.

Input vs. Events

Input is data generated by the drivers and sent up to the scenes through the ViewPort. There is a limited set of input types and they are standardized so that the drivers can be built independently of the scenes. Input follows certain rules about which scene receives them

Events are messages that one scene generates for consumption by other scenes. For example, a Component.Button scene would generate a {:click, msg} event that is sent to its parent scene.

You can generate any message you want, however, the standard component libraries follow certain patterns to keep things sensible.

Input Handling

You handle incoming input events by adding handle_input/3 functions to your scene. Each handle_input/3 call passes in the input message itself, an input context struct, and your scene's state. You can then take the appropriate actions, including generating events (below) in response.

Under normal operation, input that is not position dependent (keys, window events, more...) is sent to the root scene. Input that does have a screen position (cursor_pos, cursor button presses, etc.) Is sent to the scene that contains the graph that was hit.

Your scene can "capture" all input of a given type so that it is sent to itself instead of the default scene for that type. this is how a text input field receives the key input. First, the user selects that field by clicking on it. In response to the cursor input, the text field captures text input (and maybe transforms its graph to show that it is selected).

Captured input types should be released when no longer needed so that normal operation can resume.

The input messages are passed on to a scene's parent if not processed.

Event Filtering

In response to input, (or anything else... a timer perhaps?), a scene can generate an event (any term), which is sent backwards up the tree of scenes that make up the current aggregate graph.

In this way, a Component.Button scene can generate a{:click, msg} event that is sent to its parent. If the parent doesn't handle it, it is sent to that scene's parent. And so on until the event reaches the root scene. If the root scene doesn't handle it either then the event is dropped.

To handle events, you add filter_event/3 functions to your scene. This function handles the event, and stop its progress backwards up the graph. It can handle it and allow it to continue up the graph. Or it can transform the event and pass the transformed version up the graph.

You choose the behavior by returning either

{:cont, msg, state}

or

{:halt, state}

Parameters passed in to filter_event/3 are the event itself, a reference to the originating scene (which you can to communicate back to it), and your scene's state.

A pattern I'm using is to handle and event at the filter and stop its progression. It also generates and sends new event to its parent. I do this instead of transforming and continuing when I want to change the originating scene.

No children

There is an optimization you can use. If you know for certain that your component will not attempt to use any components, you can set has_children to false like this.

use Scenic.Component, has_children: false

Setting has_children to false this will do two things. First, it won't create a dynamic supervisor for this scene, which saves some resources.

For example, the Button component sets has_children to false.

Link to this section Summary

Callbacks

Invoked when the Scene receives an event from another scene

Invoked to handle synchronous call/3 messages. call/3 will block until a reply is received

Invoked to handle asynchronous cast/2 messages

Invoked to handle continue instructions

Invoked to handle all other messages

Invoked when the Scene receives input from a driver

Invoked when the Scene is started

Invoked when the Scene is about to exit. It should do any cleanup required

Link to this section Types

Link to this type

response_opts() View Source
response_opts() :: [
  timeout: non_neg_integer(),
  hibernate: true,
  continue: term(),
  push: graph :: Scenic.Graph.t()
]

Link to this section Functions

Link to this function

cast(scene_or_graph_key, msg) View Source

cast a message to a scene.

Link to this function

cast_to_refs(graph_key_or_id, msg) View Source

Link to this function

handle_call(msg, from, state) View Source

Link to this function

internal_push_graph(graph, sub_id, state) View Source

Link to this function

send_event(scene_server, event) View Source
send_event(scene_server :: GenServer.server(), event :: Scenic.ViewPort.event()) ::
  :ok

send a filterable event to a scene.

This is very similar in feel to casting a message to a GenServer. However, This message will be handled by the Scene's filter_event3 function. If the Scene returns {:continue, msg, state} from filter_event3, then the event will also be sent to the scene's parent. This will continue until the message reaches the root scene or some other permanently supervised scene.

Typically, when a scene wants to initiate an event, it will call send_event/1 which is a private function injected into the scene during use Scenic.Scene

This private version of send_event will take care of the housekeeping of tracking the parent's pid. It will, in turn, call this function on the main Scene module to send the event on its way.

def handle_input( {:cursor_button, {:left, :release, _, _}}, _, %{msg: msg} = state ) do
  send_event( {:click, msg} )
  {:noreply, state}
end

On the other hand, if you know you want to send the event to a named scene that you supervise yourself, then you would use Scenic.Scene.send_event/2

def handle_input( {:cursor_button, {:left, :release, _, _}}, _, %{msg: msg} = state ) do
  Scenic.Scene.send_event( :some_scene, {:click, msg} )
  {:noreply, state}
end

Be aware that named scenes that your supervise yourself are unable to continue the event to their parent in the graph because they could be referenced by multiple graphs making the continuation ambiguous.

Link to this section Callbacks

Link to this callback

filter_event(event, from, state) View Source
filter_event(event :: term(), from :: pid(), state :: term()) ::
  {:noreply, new_state}
  | {:noreply, new_state}
  | {:noreply, new_state, timeout()}
  | {:noreply, new_state, :hibernate}
  | {:noreply, new_state, opts :: response_opts()}
  | {:halt, new_state}
  | {:halt, new_state, timeout()}
  | {:halt, new_state, :hibernate}
  | {:halt, new_state, opts :: response_opts()}
  | {:cont, event, new_state}
  | {:cont, event, new_state, timeout()}
  | {:cont, event, new_state, :hibernate}
  | {:cont, event, new_state, opts :: response_opts()}
  | {:stop, reason, new_state}
when new_state: term(), reason: term(), event: term()

Invoked when the Scene receives an event from another scene.

Events are messages generated by a scene, that are passed backwards up the ViewPort's scene supervision tree. This is opposed to "input", which comes directly from the drivers.

When an event arrives at a scene, you can consume it, or pass it along to the scene above you in the ViewPort's supervision structure.

To consume the input and have processing stop afterward, return either a {:halt, ...} or {:noreply, ...} value. They are effectively the same thing.

To allow the scene's parent to process the input, return {:cont, event, state, ...}. Note that you can pass along the event unchanged or transform it in the process if you wish.

The callback supports all the return values of the init callback in Genserver.

In addition to the normal return values defined by GenServer, a Scene can add an optional {push: graph} term, which pushes the graph to the viewport.

This has replaced push_graph() as the preferred way to push a graph.

Link to this callback

handle_call(request, from, state) View Source
handle_call(request :: term(), from :: GenServer.from(), state :: term()) ::
  {:reply, reply, new_state}
  | {:reply, reply, new_state, timeout()}
  | {:reply, reply, new_state, :hibernate}
  | {:reply, reply, new_state, opts :: response_opts()}
  | {:noreply, new_state}
  | {:noreply, new_state, timeout()}
  | {:noreply, new_state, :hibernate}
  | {:noreply, new_state, opts :: response_opts()}
  | {:stop, reason, reply, new_state}
  | {:stop, reason, new_state}
when reply: term(), new_state: term(), reason: term()

Invoked to handle synchronous call/3 messages. call/3 will block until a reply is received.

The callback supports all the return values of the handle_call callback in Genserver.

In addition to the normal return values defined by GenServer, a Scene can add an optional {push: graph} term, which pushes the graph to the viewport.

This has replaced push_graph() as the preferred way to push a graph.

Link to this callback

handle_cast(request, state) View Source
handle_cast(request :: term(), state :: term()) ::
  {:noreply, new_state}
  | {:noreply, new_state, timeout()}
  | {:noreply, new_state, :hibernate}
  | {:noreply, new_state, opts :: response_opts()}
  | {:stop, reason :: term(), new_state}
when new_state: term()

Invoked to handle asynchronous cast/2 messages.

request is the request message sent by a cast/2 and state is the current state of the Scene.

The callback supports all the return values of the handle_cast callback in Genserver.

In addition to the normal return values defined by GenServer, a Scene can add an optional {push: graph} term, which pushes the graph to the viewport.

This has replaced push_graph() as the preferred way to push a graph.

Link to this callback

handle_continue(continue, state) View Source
handle_continue(continue :: term(), state :: term()) ::
  {:noreply, new_state}
  | {:noreply, new_state, timeout()}
  | {:noreply, new_state, :hibernate}
  | {:noreply, new_state, opts :: response_opts()}
  | {:stop, reason :: term(), new_state}
when new_state: term()

Invoked to handle continue instructions.

It is useful for performing work after initialization or for splitting the work in a callback in multiple steps, updating the process state along the way.

The callback supports all the return values of the handle_continue callback in Genserver.

In addition to the normal return values defined by GenServer, a Scene can add an optional {push: graph} term, which pushes the graph to the viewport.

This has replaced push_graph() as the preferred way to push a graph.

Link to this callback

handle_info(msg, state) View Source
handle_info(msg :: :timeout | term(), state :: term()) ::
  {:noreply, new_state}
  | {:noreply, new_state, timeout()}
  | {:noreply, new_state, :hibernate}
  | {:noreply, new_state, opts :: response_opts()}
  | {:stop, reason :: term(), new_state}
when new_state: term()

Invoked to handle all other messages.

msg is the message and state is the current state of the Scene. When a timeout occurs the message is :timeout.

Return values are the same as handle_cast/2.

In addition to the normal return values defined by GenServer, a Scene can add an optional {push: graph} term, which pushes the graph to the viewport.

This has replaced push_graph() as the preferred way to push a graph.

Link to this callback

handle_input(input, context, state) View Source
handle_input(
  input :: any(),
  context :: Scenic.ViewPort.Context.t(),
  state :: any()
) ::
  {:noreply, new_state}
  | {:noreply, new_state}
  | {:noreply, new_state, timeout()}
  | {:noreply, new_state, :hibernate}
  | {:noreply, new_state, opts :: response_opts()}
  | {:halt, new_state}
  | {:halt, new_state, timeout()}
  | {:halt, new_state, :hibernate}
  | {:halt, new_state, opts :: response_opts()}
  | {:cont, input, new_state}
  | {:cont, input, new_state, timeout()}
  | {:cont, input, new_state, :hibernate}
  | {:cont, input, new_state, opts :: response_opts()}
  | {:stop, reason, new_state}
when new_state: term(), reason: term(), input: term()

Invoked when the Scene receives input from a driver.

Input is messages sent directly from a driver, usually based on some action by the user. This is opposed to "events", which are generated by other scenes.

When input arrives at a scene, you can consume it, or pass it along to the scene above you in the ViewPort's supervision structure.

To consume the input and have processing stop afterward, return either a {:halt, ...} or {:noreply, ...} value. They are effectively the same thing.

To allow the scene's parent to process the input, return {:cont, input, state, ...}. Note that you can pass along the input unchanged or transform it in the process if you wish.

The callback supports all the return values of the init callback in Genserver.

In addition to the normal return values defined by GenServer, a Scene can add an optional {push: graph} term, which pushes the graph to the viewport.

This has replaced push_graph() as the preferred way to push a graph.

Link to this callback

init(args, options) View Source
init(args :: term(), options :: list()) ::
  {:ok, new_state}
  | {:ok, new_state, timeout :: non_neg_integer()}
  | {:ok, new_state, :hibernate}
  | {:ok, new_state, opts :: response_opts()}
  | :ignore
  | {:stop, reason :: any()}
when new_state: any()

Invoked when the Scene is started.

args is the argument term you passed in via config or ViewPort.set_root.

options is a list of information giving you context about the environment the scene is running in. If an option is not in the list, then it should be treated as nil.

  • :viewport - This is the pid of the ViewPort that is managing this dynamic scene. It will be not set, or nil, if you are managing the Scene in a static supervisor yourself.
  • :styles - This is the map of styles that your scene can choose to inherit (or not) from its parent scene. This is typically used by a child control that wants to visually fit into its parent's look.
  • :id - This is the :id term that the parent set a component when it was invoked.

The callback supports all the return values of the init callback in Genserver.

In addition to the normal return values defined by GenServer, a Scene can return two new ones that push a graph to the viewport

Returning {:ok, state, push: graph} will push the indicated graph to the ViewPort. This is preferable to the old push_graph() function.

Link to this callback

terminate(reason, state) View Source
terminate(reason, state :: term()) :: term()
when reason: :normal | :shutdown | {:shutdown, term()}

Invoked when the Scene is about to exit. It should do any cleanup required.

reason is exit reason and state is the current state of the Scene. The return value is ignored.

The callback is identical to the terminate callback in Genserver.