diff --git a/lib/kino/remote_execution_cell.ex b/lib/kino/remote_execution_cell.ex index 002b7437..067bc74b 100644 --- a/lib/kino/remote_execution_cell.ex +++ b/lib/kino/remote_execution_cell.ex @@ -41,13 +41,14 @@ defmodule Kino.RemoteExecutionCell do intellisense_node = intellisense_node(fields) - ctx = assign(ctx, fields: fields) + code = attrs["code"] || @default_code + + ctx = assign(ctx, fields: fields, code: code) {:ok, ctx, editor: [ - attribute: "code", + source: code, language: "elixir", - default_source: @default_code, intellisense_node: intellisense_node ]} end @@ -97,9 +98,14 @@ defmodule Kino.RemoteExecutionCell do {:noreply, ctx} end + @impl true + def handle_editor_change(source, ctx) do + {:ok, assign(ctx, code: source)} + end + @impl true def to_attrs(ctx) do - ctx.assigns.fields + Map.put(ctx.assigns.fields, "code", ctx.assigns.code) end @impl true diff --git a/lib/kino/smart_cell.ex b/lib/kino/smart_cell.ex index 5d66179e..1563bae7 100644 --- a/lib/kino/smart_cell.ex +++ b/lib/kino/smart_cell.ex @@ -153,13 +153,62 @@ defmodule Kino.SmartCell do @impl true def init(attrs, ctx) do # ... - {:ok, ctx, editor: [attribute: "code", language: "elixir"]} + {:ok, ctx, editor: [source: "", language: "elixir"]} + end + + You also need to define `c:handle_editor_change/2`, which usually + simply stores the new source in the context. + + ### Example + + Here is a minimal example, similar to the one before, but using the + editor feature. + + defmodule KinoDocs.SmartCell.Editor do + use Kino.JS + use Kino.JS.Live + use Kino.SmartCell, name: "Built-in code editor" + + @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 + attrs["source"] + end + + asset "main.js" do + """ + export function init(ctx, payload) { + ctx.importCSS("main.css"); + + ctx.root.innerHTML = `Editor:`; + } + """ + end end ### Options - * `:attribute` - the key to put the source text under in `attrs`. - Required + * `:source` - the initial editor source. Required * `:language` - the editor language, used for syntax highlighting. Defaults to `nil` @@ -167,13 +216,14 @@ defmodule Kino.SmartCell do * `:placement` - editor placement within the smart cell, either `:top` or `:bottom`. Defaults to `:bottom` - * `:default_source` - the initial editor source. Defaults to `""` - * `:intellisense_node` - a `{node, cookie}` atom tuple specifying a remote node that should be introspected for editor intellisense. This is only applicable when `:language` is Elixir. Defaults to `nil` + Note that you can programmatically reconfigure some of these options + later using `Kino.JS.Live.Context.reconfigure_smart_cell/2`. + ## Other options Other than the editor configuration, the following options are @@ -183,6 +233,7 @@ defmodule Kino.SmartCell do whenever the generated source code changes. This option may be helpful in cases where the cell output is a crucial element of the UI interactions. Defaults to `false` + ''' require Logger @@ -255,7 +306,19 @@ defmodule Kino.SmartCell do {:ok, result :: any()} | {:error, Exception.kind(), error :: any(), Exception.stacktrace()} - @optional_callbacks scan_binding: 3, scan_eval_result: 2 + @doc """ + Invoked when the smart cell editor content changes. + + Usually you just want to put the new source in the corresponding + assign. + + This callback is required if the smart cell enables editor in the + `c:Kino.JS.Live.init/2` configuration. + """ + @callback handle_editor_change(source :: String.t(), ctx :: Context.t()) :: + {:ok, ctx :: Context.t()} + + @optional_callbacks scan_binding: 3, scan_eval_result: 2, handle_editor_change: 2 defmacro __using__(opts) do quote location: :keep, bind_quoted: [opts: opts] do diff --git a/lib/kino/smart_cell/server.ex b/lib/kino/smart_cell/server.ex index 61d7012f..76dba81a 100644 --- a/lib/kino/smart_cell/server.ex +++ b/lib/kino/smart_cell/server.ex @@ -18,12 +18,13 @@ defmodule Kino.SmartCell.Server do {:ok, pid, source, chunks, init_opts} -> editor = if editor_opts = init_opts[:editor] do - source = attrs[editor_opts[:attribute]] || editor_opts[:default_source] + # TODO: remove on v1.0 + legacy_source = attrs[editor_opts[:attribute]] || editor_opts[:default_source] %{ + source: editor_opts[:source] || legacy_source, language: editor_opts[:language], placement: editor_opts[:placement], - source: source, intellisense_node: editor_opts[:intellisense_node] } end @@ -69,13 +70,22 @@ defmodule Kino.SmartCell.Server do @impl true def init({module, ref, initial_attrs, target_pid}) do {:ok, ctx, init_opts} = Kino.JS.Live.Server.call_init(module, initial_attrs, ref) - init_opts = validate_init_opts!(init_opts) + init_opts = validate_init_opts!(init_opts, module) - editor_source_attr = get_in(init_opts, [:editor, :attribute]) + editor? = init_opts[:editor] != nil reevaluate_on_change = Keyword.get(init_opts, :reevaluate_on_change, false) + # TODO: remove on v1.0 + editor_source_attr = get_in(init_opts, [:editor, :attribute]) + + if editor_source_attr == nil and editor? and + not has_function?(module, :handle_editor_change, 2) do + raise ArgumentError, + "#{inspect(module)} must define handle_editor_change/2 when the smart cell editor is enabled" + end attrs = module.to_attrs(ctx) + # TODO: remove on v1.0 attrs = if editor_source_attr do source = initial_attrs[editor_source_attr] || init_opts[:editor][:default_source] @@ -86,7 +96,6 @@ defmodule Kino.SmartCell.Server do {source, chunks} = to_source(module, attrs) - editor? = editor_source_attr != nil ctx = put_in(ctx.__private__[:smart_cell], %{editor?: editor?}) :proc_lib.init_ack({:ok, self(), source, chunks, init_opts}) @@ -103,23 +112,35 @@ defmodule Kino.SmartCell.Server do :gen_server.enter_loop(__MODULE__, [], state) end - defp validate_init_opts!(opts) do + defp validate_init_opts!(opts, module) do opts |> Keyword.validate!([:editor, :reevaluate_on_change]) |> Keyword.update(:editor, nil, fn editor_opts -> + # TODO: remove :attribute and :default_source on v1.0 + + if Keyword.has_key?(editor_opts, :attribute) do + require Logger + + Logger.warning( + "[#{inspect(module)}] the editor option :attribute is deprecated, please refer" <> + " to the documentation to learn about the new API" + ) + else + unless Keyword.has_key?(editor_opts, :source) do + raise ArgumentError, "missing required editor option :source" + end + end + editor_opts = Keyword.validate!(editor_opts, [ + :source, :attribute, - :language, - :intellisense_node, + language: nil, + intellisense_node: nil, placement: :bottom, default_source: "" ]) - unless Keyword.has_key?(editor_opts, :attribute) do - raise ArgumentError, "missing editor option :attribute" - end - unless editor_opts[:placement] in [:top, :bottom] do raise ArgumentError, "editor :placement must be either :top or :bottom, got #{inspect(editor_opts[:placement])}" @@ -150,13 +171,19 @@ defmodule Kino.SmartCell.Server do @impl true def handle_info({:editor_source, source}, state) do - attrs = Map.put(state.attrs, state.editor_source_attr, source) - {:noreply, set_attrs(state, attrs)} + if state.editor_source_attr do + # TODO: remove this branch on v1.0 + attrs = Map.put(state.attrs, state.editor_source_attr, source) + {:noreply, set_attrs(state, attrs)} + else + {:ok, ctx} = state.module.handle_editor_change(source, state.ctx) + {:noreply, put_context(state, ctx)} + end end def handle_info(msg, state) do case Kino.JS.Live.Server.call_handle_info(msg, state.module, state.ctx) do - {:ok, ctx} -> {:noreply, %{state | ctx: ctx} |> handle_reconfigure() |> recompute_attrs()} + {:ok, ctx} -> {:noreply, put_context(state, ctx)} :error -> {:noreply, state} end end @@ -166,9 +193,16 @@ defmodule Kino.SmartCell.Server do Kino.JS.Live.Server.call_terminate(reason, state.module, state.ctx) end + defp put_context(state, ctx) do + %{state | ctx: ctx} + |> handle_reconfigure() + |> recompute_attrs() + end + defp recompute_attrs(state) do attrs = state.module.to_attrs(state.ctx) + # TODO: remove on v1.0 attrs = if state.editor_source_attr do Map.put(attrs, state.editor_source_attr, state.attrs[state.editor_source_attr])