From 1a26c8e405bf3ab6a057df5a08e0cbdb3e840b99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Wed, 31 Jan 2024 22:54:17 +0800 Subject: [PATCH 1/2] Make the smart cell editor source explicitly managed --- lib/kino/remote_execution_cell.ex | 14 ++++-- lib/kino/smart_cell.ex | 75 ++++++++++++++++++++++++++++--- lib/kino/smart_cell/server.ex | 60 ++++++++++++------------- 3 files changed, 107 insertions(+), 42 deletions(-) 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..9515fb97 100644 --- a/lib/kino/smart_cell/server.ex +++ b/lib/kino/smart_cell/server.ex @@ -18,12 +18,10 @@ 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] - %{ + source: editor_opts[:source], language: editor_opts[:language], placement: editor_opts[:placement], - source: source, intellisense_node: editor_opts[:intellisense_node] } end @@ -71,22 +69,18 @@ defmodule Kino.SmartCell.Server do {:ok, ctx, init_opts} = Kino.JS.Live.Server.call_init(module, initial_attrs, ref) init_opts = validate_init_opts!(init_opts) - 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) - attrs = module.to_attrs(ctx) + if 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 = - if editor_source_attr do - source = initial_attrs[editor_source_attr] || init_opts[:editor][:default_source] - Map.put(attrs, editor_source_attr, source) - else - attrs - end + attrs = module.to_attrs(ctx) {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}) @@ -96,7 +90,6 @@ defmodule Kino.SmartCell.Server do ctx: ctx, target_pid: target_pid, attrs: attrs, - editor_source_attr: editor_source_attr, reevaluate_on_change: reevaluate_on_change } @@ -107,17 +100,22 @@ defmodule Kino.SmartCell.Server do opts |> Keyword.validate!([:editor, :reevaluate_on_change]) |> Keyword.update(:editor, nil, fn editor_opts -> + if Keyword.has_key?(editor_opts, :attribute) do + raise ArgumentError, + "the editor option :attribute is no longer supported, please refer" <> + " to the documentation to learn about the new API" + end + editor_opts = Keyword.validate!(editor_opts, [ - :attribute, - :language, - :intellisense_node, - placement: :bottom, - default_source: "" + :source, + language: nil, + intellisense_node: nil, + placement: :bottom ]) - unless Keyword.has_key?(editor_opts, :attribute) do - raise ArgumentError, "missing editor option :attribute" + unless Keyword.has_key?(editor_opts, :source) do + raise ArgumentError, "missing required editor option :source" end unless editor_opts[:placement] in [:top, :bottom] do @@ -150,13 +148,13 @@ 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)} + {:ok, ctx} = state.module.handle_editor_change(source, state.ctx) + {:noreply, put_context(state, ctx)} 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,16 +164,14 @@ 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) - - attrs = - if state.editor_source_attr do - Map.put(attrs, state.editor_source_attr, state.attrs[state.editor_source_attr]) - else - attrs - end - set_attrs(state, attrs) end From f89d922026620c8192ca0e66a103f5a1ebd4a690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Thu, 1 Feb 2024 16:45:08 +0800 Subject: [PATCH 2/2] Add deprecations --- lib/kino/smart_cell/server.ex | 66 +++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/lib/kino/smart_cell/server.ex b/lib/kino/smart_cell/server.ex index 9515fb97..76dba81a 100644 --- a/lib/kino/smart_cell/server.ex +++ b/lib/kino/smart_cell/server.ex @@ -18,8 +18,11 @@ defmodule Kino.SmartCell.Server do {:ok, pid, source, chunks, init_opts} -> editor = if editor_opts = init_opts[:editor] do + # TODO: remove on v1.0 + legacy_source = attrs[editor_opts[:attribute]] || editor_opts[:default_source] + %{ - source: editor_opts[:source], + source: editor_opts[:source] || legacy_source, language: editor_opts[:language], placement: editor_opts[:placement], intellisense_node: editor_opts[:intellisense_node] @@ -67,18 +70,30 @@ 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? = 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? and not has_function?(module, :handle_editor_change, 2) do + 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] + Map.put(attrs, editor_source_attr, source) + else + attrs + end + {source, chunks} = to_source(module, attrs) ctx = put_in(ctx.__private__[:smart_cell], %{editor?: editor?}) @@ -90,34 +105,42 @@ defmodule Kino.SmartCell.Server do ctx: ctx, target_pid: target_pid, attrs: attrs, + editor_source_attr: editor_source_attr, reevaluate_on_change: reevaluate_on_change } :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 - raise ArgumentError, - "the editor option :attribute is no longer supported, please refer" <> - " to the documentation to learn about the new API" + 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: nil, intellisense_node: nil, - placement: :bottom + placement: :bottom, + default_source: "" ]) - unless Keyword.has_key?(editor_opts, :source) do - raise ArgumentError, "missing required editor option :source" - end - unless editor_opts[:placement] in [:top, :bottom] do raise ArgumentError, "editor :placement must be either :top or :bottom, got #{inspect(editor_opts[:placement])}" @@ -148,8 +171,14 @@ defmodule Kino.SmartCell.Server do @impl true def handle_info({:editor_source, source}, state) do - {:ok, ctx} = state.module.handle_editor_change(source, state.ctx) - {:noreply, put_context(state, ctx)} + 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 @@ -172,6 +201,15 @@ defmodule Kino.SmartCell.Server do 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]) + else + attrs + end + set_attrs(state, attrs) end