Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make the smart cell editor source explicitly managed #391

Merged
merged 2 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading