Ratatouille is a declarative terminal UI kit for Elixir for building richtext-based terminal applications similar to how you write HTML.
It builds on top of the termbox API (using the Elixir bindings fromex_termbox).
For the API Reference, see: https://hexdocs.pm/ratatouille.
Toby, a terminal-based Erlang observer built with Ratatouille
Table of Contents
Ratatouille implements The Elm Architecture (TEA) as a way to structureapplication logic. This fits quite naturally in Elixir and is part of what makesRatatouille declarative. If you've already used TEA on the web, this should feelvery familiar.
As with a GenServer definition, Ratatouille apps only implement a behaviour bydefining callbacks and don't know how to start or run themselves. It's theapplication runtime that handles all of those (sometimes tricky) details.
Let's build a simple application that displays an integer counter which can beincremented when the user presses +
and decremented when the user presses -
.
First a quick clarification, since we're using the word "application" a lot. Forour purposes, an application is a terminal application, and not necessarily anOTP application, but your terminal application could also be an OTPapplication. We'll cover that in Packaging and DistributingApplications below.
Back to the counter app. First we'll look at the entire example, then we'll gothrough it line by line to see what each line does. You can also find thisexample in the repo and run it with mix run
.
# examples/counter.exs
defmodule Counter do
@behaviour Ratatouille.App
import Ratatouille.View
def init(_context), do: 0
def update(model, msg) do
case msg do
{:event, %{ch: ?+}} -> model + 1
{:event, %{ch: ?-}} -> model - 1
_ -> model
end
end
def render(model) do
view do
label(content: "Counter is #{model} (+/-)")
end
end
end
Ratatouille.run(Counter)
At the top, we define a new module (Counter
) for the app and we inform Elixirthat it will implement the Ratatouille.App
behaviour. This just ensures we'rewarned if we forget to implement a callback and serves as documentation thatthis is a Ratatouille app.
defmodule Counter do
@behaviour Ratatouille.App
# ...
end
Next, we import the View DSL fromRatatouille.View
:
import Ratatouille.View
The View DSL provides element builder functions like view
, row
, table
,label
that you can use to define views. Think of them like HTML tags.
init/1
The init/1
callback defines the initial model. "Model" is the Elmarchitecture's term for what we often call "state" in Elixir/Erlang. As with aGenServer, the state (our model) will later be passed to callbacks when thingshappen in order to allow the app to update it.
The model can be any Erlang term. For larger apps, it's helpful to use maps orstructs to organize different pieces of the state. Here, we just have an integercounter, so we return 0
as our initial model:
defmodule Counter do
# ...
def init(_context), do: 0
# ...
end
update/2
The update/2
callback defines how to transform the model when a particularmessage is received. Ratatouille's runtime will automatically call update/2
when terminal events occur (pressing a key, resizing the window, clicking themouse, etc.). We can also send ourselves messages via subscriptions and commands.
Here, we'd like to increment the counter when we get a ?+
key press anddecrement it when get a ?-
. Event messages are based on the underlying termboxevents and characters are given as code points (e.g., ?a
is 97
).
defmodule Counter do
# ...
def update(model, msg) do
case msg do
{:event, %{ch: ?+}} -> model + 1
{:event, %{ch: ?-}} -> model - 1
_ -> model
end
end
# ...
end
It's a good idea to provide a fallback clause in case we don't know how tohandle a message. This way the app won't crash if the user presses a key thatthe app doesn't handle. But if things stop working as you expect, try removingthe fallback to see if important messages are going unmatched.
render/1
The render/1
callback defines a view to display the model. The runtime willcall it as needed when it needs to update the terminal window.
Like an HTML document, a view is defined as a tree of elements (nodes).Elements have attributes (e.g., text: bold
) and children (nested content).While helper functions can return arbitrary element trees, the render/1
callback must return a view tree starting with a root view
element---it'ssort of like the <body>
tag in HTML.
defmodule Counter do
# ...
def render(model) do
view do
label(content: "Counter is #{model} (+/-)")
end
end
# ...
end
There's a final and very important line at the bottom:
Ratatouille.run(Counter)
This starts the application runtime with our app definition. Options can bepassed as a second argument. This is an easy way to run simple apps. For morecomplicated ones, it's recommended to define an OTP application.
That's it---now you can run the program with mix run <file>
. To run thebundled example:
$ mix run examples/counter.exs
You should see the counter we defined, be able to make changes to it with +
and -
, and be able to quit using q
.
Ratatouille's views are trees of elements similar to HTML in structure. Forexample, here's how to define a two-column layout:
view do
row do
column size: 6 do
panel title: "Left Column" do
label(content: "Text on the left")
end
end
column size: 6 do
panel title: "Right Column" do
label(content: "Text on the right")
end
end
end
end
As you might have noticed, Ratatouille provides a small DSL on top of Elixir fordefining views. These are functions and macros which accept attributes and/orchild elements in different formats. For example, a column
element can bedefined in all of the following ways:
column()
column(size: 12)
column do
# ... child elements ...
end
column size: 12 do
# ... child elements ...
end
All of these evaluate to a %Ratatouille.Renderer.Element{tag: :column}
struct.The macros provide syntactic sugar, but under the hood it's all structs.
Here's a list of all the elements provided by Ratatouille.View
:
Element | Description |
---|---|
bar |
Block-level element for creating title, status or menu bars |
canvas |
A free-form canvas for drawing arbitrary shapes |
canvas_cell |
A canvas cell which represents one square of the canvas |
chart |
Element for plotting a series as a multi-line chart |
column |
Container occupying a vertical segment of the grid |
label |
Block-level element for displaying text |
overlay |
Container overlaid on top of the view |
panel |
Container with a border and title used to demarcate content |
row |
Container used to define grid layouts with one or more columns |
sparkline |
Element for plotting a series in a single line |
table |
Container for displaying data in rows and columns |
table_cell |
Element representing a table cell |
table_row |
Container representing a row of the table |
text |
Inline element for displaying uniformly-styled text |
tree |
Container for displaying data as a tree of nodes |
tree_node |
Container representing a tree node |
view |
Top-level container |
Because it's just Elixir code, you can freely mix in Elixir syntax and abstractviews using functions:
label(content: a_variable)
view do
case current_tab do
:one -> render_tab_one()
:two -> render_tab_two()
end
end
if window.width > 80 do
row do
column(size: 6)
column(size: 6)
end
else
row do
column(size: 12)
end
end
Attributes are used to style text and other content:
# Labels are block-level, so this makes text within the whole block red.
label(content: "Red text", color: :red)
# Nested inline text elements can be used to style differently within a label.
label do
text(content: "R", color: :red)
text(content: "G", color: :green)
text(content: "B", color: :blue)
end
# `color` sets the foreground, while `background` sets the background.
label(content: "Black on white", color: :black, background: :white)
# `attributes` accepts a list of text attributes, here `:bold` and `:underline`.
label(content: "Bold and underlined text", attributes: [:bold, :underline])
Styling is still being developed, so it's not currently possible to style everyaspect of every element, but this will improve with time.
Most web browsers will happily try to make sense of any HTML you give them. Forexample, you can put a td
directly under a div
and the content will likelystill be rendered.
Ratatouille takes a different, more strict approach and first validates that theview tree is well-structured. If it's not valid, an error is raised explainingthe problem. This is intended to provide quick feedback when something's wrong.Restricting the set of valid views also helps to simplify the renderingimplementation.
It's helpful to keep the following things in mind when defining views:
row
may only haveelements with the column
tag as direct descendants.view
element must be the root element of any view tree you'd like torender.See the list of elements above for documentation on each element.
The following examples show off different aspects of the framework:
Name | Description |
---|---|
rendering.exs |
A rendering demo of all the supported elements |
counter.exs |
How to create a simple app with state and updates |
editor.exs |
How to use receive and display user input |
multiple_views.exs |
How to render different views/tabs based on a selection |
subscriptions.exs |
How to subscribe to multiple intervals |
commands.exs |
How to run commands asynchronously and receive the results |
snake.exs |
How to make a simple game |
documentation_browser.exs |
How to render and scroll multiline content |
With the repository cloned locally, run an example withmix run examples/<example>.exs
. Examples can be quit with q
or CTRL-c
(unless indicated otherwise).
The application runtime abstracts away many of the details concerning how theterminal window is updated and how events are received. If you're interested inhow these things actually work, or if the runtime doesn't support your use case,see this guide:
https://hexdocs.pm/ratatouille/under-the-hood.html
Warning: This part is still rough around the edges.
While it's easy to run apps while developing with mix run
, packaging them forothers to easily run is a bit more complicated. Depending on the type of appyou're building, it might not be reasonable to assume that users have anyElixir or Erlang tools installed. Terminal apps are usually distributed asbinary executables so that they can just be run as such without additionaldependencies. Fortunately, this is possible using OTP releases that bundleERTS.
In order to create an OTP release, we first need to define an OTP applicationthat runs the terminal application. Ratatouille.Runtime.Supervisor
takes careof starting all the necessary runtime components, so we start this supervisorunder the OTP application supervisor and pass it a Ratatouille app definition(along with any other runtime configuration).
For example, the OTP application for toby looks like this:
defmodule Toby do
use Application
def start(_type, _args) do
children = [
{Ratatouille.Runtime.Supervisor, runtime: [app: Toby.App]},
# other workers...
]
Supervisor.start_link(
children,
strategy: :one_for_one,
name: Toby.Supervisor
)
end
end
We'll use Distillery to create the OTP release, as it can even createdistributable, self-contained executables. Releases built on a givenarchitecture can generally be run on machines of the same architecture.
Follow the Distillery guide to generate a release configuration:
https://hexdocs.pm/distillery/introduction/installation.html
In order to make a "batteries-included" release, it's important that you haveinclude_erts
set to true
:
environment :prod do
# ...
set(include_erts: true)
# ...
end
Now it's possible to generate the release:
MIX_ENV=prod mix release --executable --transient
This creates a Distillery release that bundles the Erlang runtime and theapplication. Start it in the foreground, e.g.:
_build/prod/rel/toby/bin/toby.run foreground
You can also move this executable somewhere else (e.g., to a directory in your$PATH). A current caveat is that it must be able to unpack itself, as Distilleryexecutables are self-extracting archives.
For inspiration or ideas on how to structure your application, check out this list ofprojects built with Ratatouille:
tefter/cli
- the command-line client for Teftertoby
- a terminal-based Erlang observerIf you have a project you'd like to include here, just open a PR to add it to the list.
Add Ratatouille as a dependency in your project's mix.exs
:
def deps do
[
{:ratatouille, "~> 0.5.0"}
]
end
To try out the master branch, first clone the repo:
git clone https://github.com/ndreynolds/ratatouille.git
cd ratatouille
Next, fetch the deps:
mix deps.get
Finally, try out one of the included examples/
:
mix run examples/rendering.exs
If you see lots of things drawn on your terminal screen, you're good to go. Use"q" to quit in the examples (unless otherwise specified).
color: :red
instead of color: Constants.color(:red)
.my_table()
) that aredefined outside of the core library.Contributions are much appreciated. They don't necessarily have to come in theform of code, I'm also very thankful for bug reports, documentationimprovements, questions, and suggestions.
Run the unit tests as usual:
mix test
Ratatouille also includes integration tests of the bundled examples. Thesearen't included in the default suite because they actually run the example apps.The integration suite can be run like so:
mix test --only integration
Copyright (c) 2018 Nick Reynolds
This software is released under the MIT License.
传送门 分析 首先把包(package)的克数 $Q_{ij}$ 转化成区间 $[\lceil Q_{ij}/(1.1 r_i )\rceil, \lfloor Q_{ij}/(0.9 r_i)\rfloor]$。可以通过将分子、分母同乘10来避免精度问题。注意某个包对应的区间可能为空,比如 $Q_{ij}=13, r_i=10$。然后将每种成分(ingredient)的包对应的区间按左右端点双关