Scenic v0.10.2 Scenic.Scene behaviour View Source
Overview
Scenes are the core of the UI model.
A Scene
has three jobs.
- Maintain any state specific to that part of your application.
- Build and maintain a graph of UI primitives that gets drawn to the screen.
- 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
- Performance: It is clearly faster to build the graph once during build time than to build it repeatedly during runtime.
- 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
response_opts()
View Source
response_opts() :: [
timeout: non_neg_integer(),
hibernate: true,
continue: term(),
push: graph :: Scenic.Graph.t()
]
response_opts() :: [ timeout: non_neg_integer(), hibernate: true, continue: term(), push: graph :: Scenic.Graph.t() ]
Link to this section Functions
cast(scene_or_graph_key, msg) View Source
cast a message to a scene.
cast_to_refs(graph_key_or_id, msg) View Source
handle_call(msg, from, state) View Source
handle_cast(msg, state) View Source
internal_push_graph(graph, sub_id, state) View Source
send_event(scene_server, event)
View Source
send_event(scene_server :: GenServer.server(), event :: Scenic.ViewPort.event()) ::
:ok
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.
terminate(reason, map) View Source
Link to this section Callbacks
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()
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.
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()
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.
handle_cast(request, state) View Source
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.
handle_continue(continue, state) View Source
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.
handle_info(msg, state) View Source
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.
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()
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.
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()
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.
terminate(reason, state) View Source
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
.