# Run as: iex --dot-iex path/to/notebook.exs # Title: Automating with Smart cells Mix.install([ {:kino, "~> 0.18.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 source = attrs["source"] || "" {:ok, assign(ctx, source: source), editor: [source: source]} end @impl true def handle_connect(ctx) do {:ok, %{}, ctx} end @impl true def handle_editor_change(source, ctx) do {:ok, assign(ctx, source: source)} end @impl true def to_attrs(ctx) do %{"source" => ctx.assigns.source} 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 json = attrs["json"] || "" ctx = assign(ctx, variable: Kino.SmartCell.prefixed_var_name("data", attrs["variable"]), json: json ) {:ok, ctx, editor: [source: 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 handle_editor_change(json, ctx) do {:ok, assign(ctx, json: json)} end @impl true def to_attrs(ctx) do %{ "variable" => ctx.assigns.variable, "json" => ctx.assigns.json } 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 = `
`; 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](https://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](https://github.com/livebook-dev). # ── 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! 🚀