diff --git a/lib/livebook/notebook/explore.ex b/lib/livebook/notebook/explore.ex index 06a3317d2..cd8e889af 100644 --- a/lib/livebook/notebook/explore.ex +++ b/lib/livebook/notebook/explore.ex @@ -105,6 +105,10 @@ defmodule Livebook.Notebook.Explore do %{ ref: :kino_custom_kinos, path: Path.join(__DIR__, "explore/kino/custom_kinos.livemd") + }, + %{ + ref: :kino_smart_cells, + path: Path.join(__DIR__, "explore/kino/smart_cells.livemd") } ] @@ -210,7 +214,8 @@ defmodule Livebook.Notebook.Explore do :kino_vm_introspection, :kino_chat_app, :kino_pong, - :kino_custom_kinos + :kino_custom_kinos, + :kino_smart_cells ] } ] diff --git a/lib/livebook/notebook/explore/kino/custom_kinos.livemd b/lib/livebook/notebook/explore/kino/custom_kinos.livemd index 9c5c12897..fa9fa1a5d 100644 --- a/lib/livebook/notebook/explore/kino/custom_kinos.livemd +++ b/lib/livebook/notebook/explore/kino/custom_kinos.livemd @@ -310,12 +310,5 @@ we defined for our counter. Kino.Counter.bump(counter) ``` -## Final words - -Congratulations, you finished our "course" on Kino! Throughout -those guides, you mastered Kino's API and learned how to use its -building blocks to build a chat app and a multiplayer pong game. - -Then, in this notebook, you learned how you can take Kino anywhere -you want by implementing your own kinos with Elixir and JavaScript. -We are looking forward to see what you can build with it! 🚀 +In the next notebook we will get back to those concepts and +[extend Livebook with custom Smart cells](/explore/notebooks/smart-cells)! diff --git a/lib/livebook/notebook/explore/kino/smart_cells.livemd b/lib/livebook/notebook/explore/kino/smart_cells.livemd new file mode 100644 index 000000000..9058cce0e --- /dev/null +++ b/lib/livebook/notebook/explore/kino/smart_cells.livemd @@ -0,0 +1,434 @@ +# Exploring Smart cells + +```elixir +Mix.install([ + {:kino, github: "livebook-dev/kino"}, + {:jason, "~> 1.3"} +]) +``` + +## 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. Under the hood, each +Smart cell is just 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. + +```elixir +defmodule Kino.Kino.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("blur", (event) => { + ctx.pushEvent("update_text", event.target.value); + }); + } + """ + end +end + +Kino.SmartCell.register(Kino.Kino.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. + + + +Now let's try out the new cell! We already inserted one below, but you +can add more with the + Smart button. + + + +```elixir +IO.puts("something") +``` + +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`. + +```elixir +defmodule Kino.Kino.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(Kino.Kino.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. + + + +```elixir +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. + +```elixir +defmodule Kino.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 = ` +
+ + +
+ `; + + const variableEl = ctx.root.querySelector(`[name="variable"]`); + variableEl.value = payload.variable; + + variableEl.addEventListener("blur", (event) => { + ctx.pushEvent("update_variable", event.target.value); + }); + + ctx.handleEvent("update_variable", (variable) => { + variableEl.value = variable; + }); + } + """ + 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(Kino.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! + + + +```elixir +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 ⚡ + +## 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! 🚀