Exploring Smart cells
Mix.install([
{:kino, "~> 0.8.0"},
{:jason, "~> 1.4"}
])
Introduction
So far we discussed how Livebook supports interactive outputs and covered creating custom outputs with Kino. Livebook v0.6 opens up the door to a whole new category of extensions that we call Smart cells.
Just like the Code and Markdown cells, Smart cells are building
blocks for our notebooks. A Smart cell provides a user interface
specifically designed for a particular task. While Kino.JS
and
Kino.JS.Live
are about customizing the output, without changing
the runtime, Smart cells run just like a regular piece of code,
however the code is generated automatically based on the UI
interactions.
Smart cells allow for accomplishing high-level tasks faster, without writing any code, yet, without sacrificing code! This characteristic makes them a productivity boost and also a great learning tool.
Go ahead, place your cursor between cells and click on the + Smart button, you should see a list of suggested Smart cells. Feel free to try them out!
As with outputs, we can define custom Smart cells through Kino, and that’s what we’re going to explore in this notebook!
Basic concepts
Smart cells consist of UI and state, which we implement using Kino.JS
and Kino.JS.Live
that we talked about in the previous guide. The
additional element is code generation. Let’s have a look at a simple
Smart cell that lets the user type text and generates code for printing
that text.
defmodule KinoGuide.PrintCell do
use Kino.JS
use Kino.JS.Live
use Kino.SmartCell, name: "Print"
@impl true
def init(attrs, ctx) do
ctx = assign(ctx, text: attrs["text"] || "")
{:ok, ctx}
end
@impl true
def handle_connect(ctx) do
{:ok, %{text: ctx.assigns.text}, ctx}
end
@impl true
def handle_event("update_text", text, ctx) do
broadcast_event(ctx, "update_text", text)
{:noreply, assign(ctx, text: text)}
end
@impl true
def to_attrs(ctx) do
%{"text" => ctx.assigns.text}
end
@impl true
def to_source(attrs) do
quote do
IO.puts(unquote(attrs["text"]))
end
|> Kino.SmartCell.quoted_to_string()
end
asset "main.js" do
"""
export function init(ctx, payload) {
root.innerHTML = `
Say what?
`;
const textEl = document.getElementById("text");
textEl.value = payload.text;
ctx.handleEvent("update_text", (text) => {
textEl.value = text;
});
textEl.addEventListener("change", (event) => {
ctx.pushEvent("update_text", event.target.value);
});
ctx.handleSync(() => {
// Synchronously invokes change listeners
document.activeElement &&
document.activeElement.dispatchEvent(new Event("change"));
});
}
"""
end
end
Kino.SmartCell.register(KinoGuide.PrintCell)
Most of the implementation includes regular Kino.JS.Live
bits
that should feel familiar, specifically init/2
, handle_connect/1
,
handle_event/3
and the JS module. Let’s go through the new parts.
Firstly, we add use Kino.SmartCell
and specify the name of our
Smart cell, that’s the name that will show up in Livebook.
Next, we define the to_attrs/1
callback responsible for serializing
the Smart cell state. The attributes are stored in the notebook source
as JSON. When opening an existing notebook, a Smart cell is started
and receives the attributes as the first argument to the init/2
callback to restore the relevant state. On initial start an empty map
is given.
The other new callback is to_source/1
. It is used to generate source
code based on the Smart cell attributes. Elixir has built in support
for source code manipulation, that’s why in our example we use the
quote
construct, instead of a less robust string interpolation.
Finally, we register our new smart cell using Kino.SmartCell.register/1
,
so that Livebook picks it up. Note that in practice we would put the
Smart cell in a package and we would register it in application.ex
when starting the application.
Note that we register a synchronization handler on the client with
ctx.handleSync(() => ...)
. This optional handler is invoked before
evaluation and it should flush any deferred UI changes to the server.
In our example we listen to input’s “change” event, which is only
triggered on blur, so on synchronization we trigger it programmatically.
Now let’s try out the new cell! We already inserted one below, but you can add more with the + Smart button.
IO.puts("Hello!")
Focus the Smart cell and click the “Source” icon. You should see the generated source code, however the editor is in read-only mode. Now switch back to the Smart cell UI, modify the input and see how the source code changes. You can evaluate the cell, as with a regular Code cell.
Collaborative editor
Livebook puts a strong emphasis on collaboration and Smart cells are no exception. The above cell works fine with multiple users, however the changes to the input are atomic, so if users edit it simultaneously, one would override the other. This behaviour is alright for small parameter inputs, however some cells may require editing a larger chunk of text, such as an SQL query or JSON data.
Livebook already provides a collaborative editor for the Code and
Markdown cells. Fortunately, a Smart cell can opt-in for an editor
as well! To showcase this feature, let’s build a cell on top of
System.shell/2
.
defmodule KinoGuide.ShellCell do
use Kino.JS
use Kino.JS.Live
use Kino.SmartCell, name: "Shell script"
@impl true
def init(_attrs, ctx) do
{:ok, ctx, editor: [attribute: "source"]}
end
@impl true
def handle_connect(ctx) do
{:ok, %{}, ctx}
end
@impl true
def to_attrs(_ctx) do
%{}
end
@impl true
def to_source(attrs) do
quote do
System.shell(
unquote(quoted_multiline(attrs["source"])),
into: IO.stream(),
stderr_to_stdout: true
)
|> elem(1)
end
|> Kino.SmartCell.quoted_to_string()
end
defp quoted_multiline(string) do
{:<<>>, [delimiter: ~s["""]], [string <> "\n"]}
end
asset "main.js" do
"""
export function init(ctx, payload) {
ctx.importCSS("main.css");
root.innerHTML = `
Shell script
`;
}
"""
end
asset "main.css" do
"""
.app {
padding: 8px 16px;
border: solid 1px #cad5e0;
border-radius: 0.5rem 0.5rem 0 0;
border-bottom: none;
}
"""
end
end
Kino.SmartCell.register(KinoGuide.ShellCell)
The tuple returned from init/2
has an optional third element that
we use to configure the Smart cell. To enable the editor, all we need
is a configuration option! The editor is fully managed by Livebook,
separately from the Smart cell UI and the editor content is placed in
attrs
under the name specified with :attribute
. This way we can
access it in to_source/1
.
In this example we don’t need any other attributes, so in the UI we only show the cell name.
System.shell(
"""
echo "There you are:"
ls -dlh $(pwd)
exit 1
""",
into: IO.stream(),
stderr_to_stdout: true
)
|> elem(1)
Stepping it up
Now that we discussed both regular inputs and the collaborative editor, it’s time to put it all together. For our final example we will build a Smart cell that takes JSON data and generates an Elixir code with a matching data structure assigned to a user-defined variable.
defmodule KinoGuide.JSONConverterCell do
use Kino.JS
use Kino.JS.Live
use Kino.SmartCell, name: "JSON converter"
@impl true
def init(attrs, ctx) do
ctx =
assign(ctx,
variable: Kino.SmartCell.prefixed_var_name("data", attrs["data"])
)
{:ok, ctx, editor: [attribute: "json", language: "json"]}
end
@impl true
def handle_connect(ctx) do
{:ok, %{variable: ctx.assigns.variable}, ctx}
end
@impl true
def handle_event("update_variable", variable, ctx) do
ctx =
if Kino.SmartCell.valid_variable_name?(variable) do
assign(ctx, variable: variable)
else
ctx
end
broadcast_event(ctx, "update_variable", ctx.assigns.variable)
{:noreply, ctx}
end
@impl true
def to_attrs(ctx) do
%{"variable" => ctx.assigns.variable}
end
@impl true
def to_source(attrs) do
case Jason.decode(attrs["json"]) do
{:ok, data} ->
quote do
unquote(quoted_var(attrs["variable"])) = unquote(Macro.escape(data))
:ok
end
|> Kino.SmartCell.quoted_to_string()
_ ->
""
end
end
defp quoted_var(nil), do: nil
defp quoted_var(string), do: {String.to_atom(string), [], nil}
asset "main.js" do
"""
export function init(ctx, payload) {
ctx.importCSS("main.css");
ctx.importCSS("https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap");
root.innerHTML = `
Parse JSON to
`;
const variableEl = ctx.root.querySelector(`[name="variable"]`);
variableEl.value = payload.variable;
variableEl.addEventListener("change", (event) => {
ctx.pushEvent("update_variable", event.target.value);
});
ctx.handleEvent("update_variable", (variable) => {
variableEl.value = variable;
});
ctx.handleSync(() => {
// Synchronously invokes change listeners
document.activeElement &&
document.activeElement.dispatchEvent(new Event("change"));
});
}
"""
end
asset "main.css" do
"""
.app {
font-family: "Inter";
display: flex;
align-items: center;
gap: 16px;
background-color: #ecf0ff;
padding: 8px 16px;
border: solid 1px #cad5e0;
border-radius: 0.5rem 0.5rem 0 0;
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: #445668;
text-transform: uppercase;
}
.input {
padding: 8px 12px;
background-color: #f8fafc;
font-size: 0.875rem;
border: 1px solid #e1e8f0;
border-radius: 0.5rem;
color: #445668;
min-width: 150px;
}
.input:focus {
outline: none;
}
"""
end
end
Kino.SmartCell.register(KinoGuide.JSONConverterCell)
In this case, one of the attributes is a variable name, so we use
Kino.SmartCell.prefixed_var_name/2
. For a fresh cell it will
generate a default variable name that isn’t already taken by another
Smart cell. You can test this by inserting another JSON cell, where
the variable name should default to data2
.
This time we also added some proper styling to show a Smart cell in its full glory!
data = [
%{
"id" => 1,
"name" => "livebook",
"topics" => ["elixir", "visualization", "realtime", "collaborative", "notebooks"],
"url" => "https://github.com/livebook-dev/livebook"
},
%{
"id" => 2,
"name" => "kino",
"topics" => ["charts", "elixir", "livebook"],
"url" => "https://github.com/livebook-dev/kino"
}
]
:ok
This cell accomplishes a coding task that would otherwise be tedious without parsing the JSON from a string. We could further extend the cell with options to convert keys to snake case or use atoms!
We went through a couple examples, however there is even more power to Smart cells than that! One feature we haven’t discussed is access to notebook variables and evaluation results. Those allow for making the UI data-driven!
Hopefully this notebook gives you a good overview of the Smart cells’ potential, now it’s your turn to unlock it. ⚡ Once your Smart cell is complete, you can publish it as a package to Hex.pm and allow anyone in the community to reuse them, or even find one that fits your needs. To learn more from examples, check out the several examples under the Livebook Organization.
Final words
Congratulations, you finished our “course” on Kino! Throughout those guides, you first mastered Kino’s API and learned how to use its building blocks to build a chat app and a multiplayer pong game.
Then, you learned how you can take Kino anywhere you want by implementing your own kinos and cells with Elixir and JavaScript. We are looking forward to see what you can build with it! 🚀