Skip to content

Commit

Permalink
Make the smart cell editor source explicitly managed (#391)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonatanklosko authored Feb 1, 2024
1 parent 9f5d073 commit 29839b7
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 25 deletions.
14 changes: 10 additions & 4 deletions lib/kino/remote_execution_cell.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
75 changes: 69 additions & 6 deletions lib/kino/smart_cell.ex
Original file line number Diff line number Diff line change
Expand Up @@ -153,27 +153,77 @@ 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`
* `: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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
64 changes: 49 additions & 15 deletions lib/kino/smart_cell/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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})
Expand All @@ -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])}"
Expand Down Expand Up @@ -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
Expand All @@ -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])
Expand Down

0 comments on commit 29839b7

Please sign in to comment.