defmodule ReflectOS.Kernel.Layout do @moduledoc """ Layouts define the arrangment of sections on the screen. ## Overview Layouts are a type of `Scenic.Scene` (which in turn is a type of GenServer) which defines the arrangement of sections on the users ReflectOS Dashboard. For example, the `FourCorners` layouts which ships with ReflectOS allows users to place sections in the top left, top right, bottom left, and bottom right corners of the dashboard. Layouts are passed their configuration, the screen size, and the sections the user has selected to display and are expected to render those sections to the screen. Like all extensions to ReflectOS, developers are also required to implement a set of callbacks which are used to allow run time configuration. If you plan on publishing your extensions for use by others in the community, you can use these callbacks to create a thoughtful and intuitive configuration experience. For the runtime UI behavior, Layouts follow many of the same paradigms as `Scenic.Scene` and therefore should be very familiar to for developers experienced building native user interfaces with the [Scenic Framework](https://hexdocs.pm/scenic). With Scenic, the UI is rendered natively (e.g. not via a webview) - this allows much better performance on the devices typically used for smart mirror projects (i.e. devices with a low-profile form factor and reduced processing power such as the `Raspberry Pi Zero W` and `Zero 2 W`). This means that Layouts take on an important responsibility in the ReflecOS system since the typical tools available in webviews (e.g. `flexbox`) are not available. ## Layout Locations Layouts define a list of available "locations" in their `ReflectOS.Kernel.Layout.Definition`, which are essentially areas of the screen where users can place one or more sections. Layouts are responsible for adding each section to the layout's graph and using `Scenic.Primitive.Transform.Translate` to ensure the section is located in the right place on the screen. ## Rendering Sections Since a `ReflectOS.Kernel.Section` is just a wrapped `Scenic.Component`, Layouts can call `c:Scenic.Component.add_to_graph/3` to render them to the the layouts graph. For example, your layout might contain the following function: def render_section(%Scenic.Graph{} = graph, %Section{} = section, layout_tracker, x, y) do %{id: section_id, module: section_module} = section graph |> section_module.add_to_graph({layout_tracker, section_id}, t: {x, y}) end Note that the `add_to_graph` function must be called with a two-part tuple in the format `{layout_tracker, section_id}` as the second argument, where `layout_tracker` is a unique id assigned by the layout used to identify the section and `section_id` is the id of the section being rendered. Note that you should avoid using the `section_id` as the layout tracker, since users are permitted to add the same section multiple times to the same layout. ## Implementing a Layout Layouts are just modules which `use ReflectOS.Kernel.Layout`. The callbacks and other requirements for a Layout fall into two major categories: 1. Configuration experience via the [ReflectOS Console](https://github.com/reflect-os/console) web ui. 2. Runtime native display rendering on the smart mirror/display ### Configuration Experience In order to drive the ReflectOS console UI, layouts are required to contain an `Ecto.Schema` which represents the available configuration options. This is typically done using the [`embedded_schema/1`](https://hexdocs.pm/ecto/Ecto.Schema.html#embedded_schema/1) macro, as they are not persisted via `Ecto.Repo`. Note that embedding schemas in your root schema (e.g. `Ecto.Schema.embeds_many/4`) is **not currently supported**. Additionally, layouts must implement the following callbacks which are used by the console: * `c:layout_definition/0` * `c:layout_options/0` * `c:changeset/2` ### Runtime native display In order to render the layout ReflectOS dashboard, modules are required to implement the following callbacks: * `c:init_layout/3` * `c:handle_section_update/3` `Layouts` can also optionally implement the following callbacks: * `c:validate_layout/1` * `c:handle_config_update/2` * `c:handle_sections_update/2` * `c:handle_viewport_update/2` See the documentation for each callback below for more details. ## Example For a complete example of a ReflectOS Layout, see the `FourCorner` layout from [ReflectOS Core](https://github.com/Reflect-OS/core/tree/main/lib/layouts/four_corners.ex), which is shipped with the pre-built system firmware. """ alias ReflectOS.Kernel.Settings.SectionStore alias Scenic.Scene alias Scenic.Graph alias ReflectOS.Kernel.Settings.System alias ReflectOS.Kernel.Settings.LayoutStore alias ReflectOS.Kernel.{Option, OptionGroup} alias ReflectOS.Kernel.Layout.Definition alias ReflectOS.Kernel.Section @doc false @type t :: %__MODULE__{ id: binary(), name: binary(), module: module(), config: map(), sections: %{ optional(atom()) => list(Section.t()) } } defstruct id: nil, name: nil, module: nil, config: %{}, sections: %{} @doc """ Provides the `ReflectOS.Kernel.Layout.Definition` struct for the layout. This is used to show your layout in the Console UI. See below for an example adapted From the `FourCorner` layout, which ships with the pre-built ReflectOS system: @doc false @impl ReflectOS.Kernel.Layout def layout_definition(), do: %Definition{ name: "Four Corner", description: fn assigns -> ~H\"\"\" Allows placing sections in each of the four corners of the screen, with options for stacking (vertical vs. horizontal) and spacing. \"\"\" end, icon: \"\"\" \"\"\", locations: [ %{key: :top_left, label: "Top Left"}, %{key: :top_right, label: "Top Right"}, %{key: :bottom_left, label: "Bottom Left"}, %{key: :bottom_right, label: "Bottom Right"} ] } Note that the `icon` property is passed in as simple string, while the `description` property takes a function which can be passed to `Phoenix.LiveView`. This allows you to use html tags in your description. Additionally, note that the argument must be called `assigns`. The `locations` property must be a list of maps with a `key` and a `label` property. See `ReflectOS.Kernel.Layout.Definition` for more details. """ @callback layout_definition() :: Definition.t() @doc """ Provides the list of options which can be configured through the ReflectOS console. General guidelines are to use `ReflectOS.Kernel.OptionGroup` to present configuration related to a specific layout location together. """ @callback layout_options() :: [Option.t() | OptionGroup.t()] @doc """ Used to cast and validate input from the user via the ReflectOS console web ui. Can be used like a standard `Ecto.Changeset`. If we had a layout called `SimpleLayout` which defined an `Ecto.Schema` with a `:spacing` field, the changeset callback might look like this: @impl true def changeset(%SimpleLayout{} = section, params \\\\ %{}) do section |> cast(params, [:spacing]) |> validate_required([:spacing]) |> validate_number(:spacing, greater_than: 0) end """ @callback changeset( layout_config :: Ecto.Schema.embedded_schema(), params :: %{binary() => any()} ) :: Ecto.Changeset.t() @doc """ Callback invoked during initialization of the layout. Wraps the `c:Scenic.Scene.init/3` callback, and allows the same return values. The first argument is the `Scenic.Scene`. The second argument is a map containing three fields: * `config`: The `Ecto.Schema` struct defined in your layout module populated with the user's configuration. * `sections`: A `Map` where each key corresponds to a one of the locations defined in `c:layout_definition/0` and values are a list of the `ReflectOS.Kernel.Section` struct. * `viewport_size`: A tuple representing the screen size in `{width, height}` format. The ReflectOS default is `{1080, 1920}` but can be adjusted by user. The last argument is a list of options which maybe passed into the layout by the system. These are not currently used but are included to ensure consistency with `Scenic.Scene`. """ @callback init_layout( scene :: Scenic.Scene.t(), args :: %{ config: Ecto.Schema.embedded_schema(), sections: %{required(atom()) => list(Section.t())}, viewport_size: {integer(), integer()} }, options :: Keyword.t() ) :: {:ok, scene} | {:ok, scene, timeout :: non_neg_integer} | {:ok, scene, :hibernate} | {:ok, scene, opts :: Scenic.Scene.response_opts()} | :ignore | {:stop, reason} when scene: Scene.t(), reason: term() @doc """ Optional callback to validate the layout config at runtime. This is likely to be rarely used, as layouts use the `c:changeset/2` callback to validate the configuration from the user, but is provided as it can be useful during development to ensure the configuration your layout is receiving matches what you expect. """ @callback validate_layout(config :: Ecto.Schema.embedded_schema()) :: :ok | {:error, error: any()} @doc """ Required callback to handle updates to sections rendered by the layout. Layouts must implement this function to handle updates to a section's graph. If a section's dimensions change, it may impact where it or other sections in the layout should be located (remember that all elements in `Scenic.Scene` are located on at fixed `x,y` location). This callback allows layouts to adjust the positioning of their sections based on the new section size. The `layout_tracker` argument is the unique id assigned to the section by the layout when it calls `c:Scenic.Component.add_to_graph/3`, see the docs on [rendering sections](#module-rendering-sections) above. """ @callback handle_section_update( layout :: Scene.t(), layout_tracker :: any(), section_graph :: Graph.t() ) :: Scene.t() @doc """ Optional callback for when a user updates a layouts's configuration while it's displayed on the ReflectOS dashboard. The default behavior when layout configuration changes is to restart the layout process with the new configuration. This will likely work in most circumstances, but you can override this behavior if it is desirable to do so. """ @callback handle_config_update(scene :: Scene.t(), config :: struct()) :: Scene.t() @doc """ Optional callback for when a user updates the arrangement of sections in the layout locations. The default behavior when this changes is to restart the layout process with the new section arrangement. This will likely work in most circumstances, but you can override this behavior if it is desirable to do so. """ @callback handle_sections_update(scene :: Scene.t(), sections :: map()) :: Scene.t() @doc """ Optional callback for when a user updates the screen size in the ReflectOS system settings. The default behavior when this changes is to restart the layout process with the new viewport size. This will likely work in most circumstances, but you can override this behavior if it is desirable to do so. """ @callback handle_viewport_update(scene :: Scene.t(), viewport_size :: tuple()) :: Scene.t() @optional_callbacks validate_layout: 1, handle_config_update: 2, handle_sections_update: 2, handle_viewport_update: 2 @doc false defmacro __using__(opts) do quote location: :keep do @behaviour unquote(__MODULE__) import Ecto.Changeset use Ecto.Schema use Scenic.Component, unquote(opts) def validate(layout_id) do layout = LayoutStore.get(layout_id) if Kernel.function_exported?(__MODULE__, :validate_layout, 1) do case Kernel.apply(__MODULE__, :validate_layout, [layout.config]) do :ok -> :ok {:error, msg} -> raise msg end end {:ok, layout_id} end def init( %Scenic.Scene{} = scene, layout_id, opts ) do layout = LayoutStore.get(layout_id) viewport_size = System.viewport_size() sections = layout.sections |> Enum.map(fn {location, section_ids} -> {location, section_ids |> Enum.map(fn id -> SectionStore.get(id) end)} end) |> Enum.into(%{}) ReflectOS.Kernel.Settings.LayoutStore.subscribe(layout_id) ReflectOS.Kernel.Settings.System.subscribe("viewport_size") args = %{ config: layout.config, sections: sections, viewport_size: viewport_size } init_layout(scene, args, opts) end # Handle update to layout config def handle_info( %PropertyTable.Event{ property: ["layouts", _layout_id], value: layout, previous_value: previous }, scene ) when previous.config != layout.config do scene = if Kernel.function_exported?(__MODULE__, :handle_config_update, 2) do config = struct(__MODULE__, layout.config) Kernel.apply(__MODULE__, :handle_config_update, [scene, config]) else exit({:shutdown, :config_update}) scene end {:noreply, scene} end # Handle update to layout sections def handle_info( %PropertyTable.Event{ property: ["layouts", _layout_id], value: layout, previous_value: previous }, scene ) when previous.sections != layout.sections do scene = if Kernel.function_exported?(__MODULE__, :handle_sections_update, 2) do Kernel.apply(__MODULE__, :handle_sections_update, [scene, layout.sections]) else exit({:shutdown, :sections_update}) scene end {:noreply, scene} end # Handle update to view port size def handle_info( %PropertyTable.Event{ property: ["system", "viewport_size"], value: layout_size }, scene ) do scene = if Kernel.function_exported?(__MODULE__, :handle_viewport_update, 2) do Kernel.apply(__MODULE__, :handle_viewport_update, [scene, layout_size]) else exit({:shutdown, :viewport_size_update}) scene end {:noreply, scene} end def handle_info(%PropertyTable.Event{} = _event, scene), do: {:noreply, scene} def handle_info( {:section_graph_updated, layout_tracker, %Scenic.Graph{} = graph}, %Scenic.Scene{} = layout ) do layout = handle_section_update(layout, layout_tracker, graph) {:noreply, layout} end end end end