Scenic - Getting started from scratch

Underjord is a tiny, wholesome team doing Elixir consulting and contract work. If you like the writing you should really try the code. See our services for more information.

This post covers setting up a Scenic project in the Elixir programming language. It briefly covers the default method but largely dives into adding Scenic to an existing project, which covers the different parts that Scenic requires to run.

The official approach

Requirements

A brand new project

Scenic, the OpenGL-based and very tasty UI framework for Elixir (& friends) has a very reasonable project generator. So if you feel like trying it out. That is a great starting point.

shell /
mix archive.install hex scenic_new
mix scenic.new my_app

With this. You will have the app, my_app and some instructions for how to download and install dependencies. For completeness:

shell /
cd my_app
mix deps.get
mix scenic.run

Et voila, you have a very simple, slightly boring view and your terminal is reporting events as you move your cursor. All you could want. This is what you get from the official docs in the section Getting started. Since I can't resist poking things and had some special requirements I did things differently. So the rest of the guide is more focused on doing it from scratch and getting to know the pieces required.

Adding Scenic to an existing project

Using a basic project

I had a project and it was a Nerves project using the nerves_init_gadget and I felt like I wanted to know whatever was brought into it and understand my dependencies and config a bit. So I started from a generated Nerves project which I'm pretty familiar with. Yes, because I keep starting them and never completing them, don't you start with me, it is my spare time, I waste it however I like. But for simplicity in this guide I will start from a minimal Elixir project.

shell /
mix new my_elixir
cd my_elixir
mix test

Oh hell, that probably worked! So what do we do to get going with Scenic? A solid first step is to install it. Note: If you get lost at any point in this guide or simply cannot get a part to work you can check the code in this repository which has the completed thing.

Dependencies

We add Scenic to our dependencies. We also want to add the driver library that allows it to work on the average desktop computer which is based on glfw.

In your project, edit mix.exs, add the two dependencies:

elixir mix.exs
..
defp deps do
    [
        {:scenic, "~> 0.10"},
        {:scenic_driver_glfw, "~> 0.10", targets: :host},
    ]
end
..

To install them after adding them, in the project, in your shell:

shell /my_elixir
    mix deps.get

This does absolutely nothing aside from making Scenic available. But we want things happening. First we need a bit of configuration for Scenic to know what to do.

Configuration

In your project find config/config.exs and open it for editing, make it match the following:

elixir config/config.exs
    use Mix.Config

    # Configure the main viewport for the Scenic application
    config :my_elixir, :viewport, %{
      name: :main_viewport,
      size: {700, 600},
      default_scene: {MyElixir.Scene.Home, nil},
      drivers: [
        %{
          module: Scenic.Driver.Glfw,
          name: :glfw,
          opts: [resizeable: false, title: "my_app"]
        }
      ]
    }

Lets go through it, shall we? use Mix.Config is just standard stuff. So we add a config :my_elixir, :viewport in which :my_elixir references our app and :viewport references that this configures a Scenic Viewport. Let's not dwell on what that is. With this we add a map with a bunch of keys. Let's run through them briefly.

  • name
    Should make things prettier in observer. Supposedly optional.
  • size
    This is the resolution/size of the scenic window. This you can change for fun and profit.
  • default_scene
    It has to start somewhere, a scene in Scenic is a specific view in your application. This is what it shows by default.
  • drivers
    This comes with some sub-configuration which we needn't go into now. But the module key tells us what driver we are configuring. This would be different if you were targeting the official touch screen for Raspberry Pi for example. This driver was provided by the scenic_driver_glfw package we installed earlier.

At this point, running will still do nothing. We have not added any running code.

The supervision tree

Let's start a supervision tree. Classic move to run anything in this day and age.

Open lib/my_elixir.ex in your editor and make it look like this:

elixir lib/my_elixir.ex
    defmodule MyElixir do
      def start(_type, _args) do
        # load the viewport configuration from config
        main_viewport_config = Application.get_env(:my_elixir, :viewport)

        # start the application with the viewport
        children = [
          {Scenic, viewports: [main_viewport_config]}
        ]

        Supervisor.start_link(children, strategy: :one_for_one)
      end
    end

Still doesn't do a single thing. Why? Well, we aren't loading the module. So we still are not running any code. Go back to mix.exs and fix this:

elixir mix.exs
    ..
    def application do
        [
          mod: {MyElixir, []},
          extra_applications: [:logger]
        ]
    end
  ..

Our app has a mod now! Or rather, our application knows which module we want to start at. I do not recommend starting it now. We have not built the default scene yet. So things will break in a rather annoying way where the supervisor will start a window over and over again because it keeps dying until you murder your app. So lets avoid that shall we?

Creating our first scene

Create a folder named scenes in lib and add a file named home.ex in that scenes folder. Open it in your editor and do something like this:

elixir lib/scenes/home.ex
    defmodule MyElixir.Scene.Home do
      use Scenic.Scene

      alias Scenic.Graph
      # alias Scenic.ViewPort

      import Scenic.Components
      # import Scenic.Primitives

      @text_size 24

      def init(_, _opts) do
        graph =
          Graph.build(font: :roboto, font_size: @text_size)
          |> button("Click me", id: :sample_button_1, t: {32, 32})

        {:ok, graph, push: graph}
      end

      def filter_event({:click, :sample_button_1}, _from, state) do
        new_text = DateTime.to_iso8601(DateTime.utc_now())

        state =
          state
          |> Graph.modify(:sample_button_1, &button(&1, new_text))

        {:halt, state, push: state}
      end
    end

This has it all, a scene, a graph, a component and even some UI event which triggers graph modification. Oh yeah. So the general ideas are covered better by the docs. But basically, init sets up a graph that contains a component, specifically a button. The graph is "pushed" as init returns. And the scene is rendered for you in beautiful free-range GL. For more information on the ideas in Scenic I'd recommend Boyd's talk at ElixirConf 2018 where he covers most of what Scenic does and why it is cool.

Well, time to run it and see what we've made.

shell /my_elixir
  iex -S mix

You should see a window with a button that updates to show the timestamp when you press it. Nothing fancy. But a start. And that is where I will end this. If you want to review the resulting code it is found here.

If you have any inquiries about this or my business, get in touch at lars@underjord.io.

Underjord is a 4 people team doing Elixir consulting and contract work. If you like the writing you should really try the code. See our services for more information.

Note: Or try the videos on the YouTube channel.