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

Add Kino.Screen #489

Merged
merged 16 commits into from
Feb 23, 2025
Merged

Add Kino.Screen #489

merged 16 commits into from
Feb 23, 2025

Conversation

josevalim
Copy link
Contributor

I choose to keep control/2 for now because using handle_event/3 requires us to wrap everything in {:noreply, ...} tuples. But if you prefer handle_event, let me know @hugobarauna / @jonatanklosko.

I also have to figure out how to control that a certain page should only be seen by a certain user (the client_id).


If someone wants to give it a try without depending on Kino main, here is a notebook:

# Kino.Screen demo

```elixir
Mix.install [:kino]
```

## Section

```elixir
defmodule Kino.Screen do
  @moduledoc ~S"""
  Provides a LiveView like experience for building forms in Livebook.

  Each screen must implement the `c:init/1` and `c:render/1` callbacks.
  Event handlers can be attached by calling the `control/2` function.
  Note the screen state is shared across all users seeing the given page.

  Let's see some examples.

  ## Dynamic select

  Here is an example that allows you to render different forms depending
  on the value of a select, each form triggering a different action:

      defmodule MyScreen do
        @behaviour Kino.Screen

        # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
        import Kino.{Control, Input, Screen}

        # In the state, we track the current selection and the frame to print results to
        def init(results_frame) do
          {:ok, %{selection: :name, frame: results_frame}}
        end

        # A form to search by name...
        def render(%{selection: :name} = state) do
          form(
            [name: text("Name")],
            submit: "Search"
          )
          |> control(&by_name/2)
          |> add_layout(state)
        end

        # A form to search by address...
        def render(%{selection: :address} = state) do
          form(
            [address: text("Address")],
            submit: "Search"
          )
          |> control(&by_address/2)
          |> add_layout(state)
        end

        # The general layout of the scren
        defp add_layout(element, state) do
          select =
            select("Search by", [name: "Name", address: "Address"], default: state.selection)
            |> control(&selection/2)

          Kino.Layout.grid([select, element, state.frame])
        end

        ## Events handlers

        defp selection(%{value: selection}, state) do
          %{state | selection: selection}
        end

        defp by_name(%{data: %{name: name}}, state) do
          Kino.Frame.render(state.frame, "SEARCHING BY NAME: #{name}")
          state
        end

        defp by_address(%{data: %{address: address}}, state) do
          Kino.Frame.render(state.frame, "SEARCHING BY ADDRESS: #{address}")
          state
        end
      end

      results_frame = Kino.Frame.new()
      Kino.Screen.new(MyScreen, results_frame)

  ## Wizard like

  Here is an example of how to build wizard like functionality with `Kino.Screen`:

      defmodule MyScreen do
        @behaviour Kino.Screen

        # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
        import Kino.{Control, Input, Screen}

        # Our screen will guide the user to provide its name and address.
        # We also have a field keeping the current page and if there is an error.
        def init(:ok) do
          {:ok, %{page: 1, name: nil, address: nil, error: nil}}
        end

        # The first screen gets the name.
        #
        # The `control/2` function comes from `Kino.Screen` and it specifies
        # which function to be invoked on form submission.
        def render(%{page: 1} = state) do
          form(
            [name: text("Name", default: state.name)],
            submit: "Step one"
          )
          |> control(&step_one/2)
          |> add_layout(state)
        end

        # The next screen gets the address.
        #
        # We also call `add_go_back/1` to add a back button.
        def render(%{page: 2} = state) do
          form(
            [address: text("Address", default: state.address)],
            submit: "Step two"
          )
          |> control(&step_two/2)
          |> add_layout(state)
        end

        # The final screen shows a success message.
        def render(%{page: 3} = state) do
          Kino.Text.new("Well done, #{state.name}. You live in #{state.address}.")
          |> add_layout(state)
        end

        # This is the layout shared across all pages.
        defp add_layout(element, state) do
          prefix = if state.error do
            Kino.Text.new("Error: #{state.error}!")
          end

          suffix = if state.page > 1 do
            button("Go back")
            |> control(&go_back/2)
          end

          [prefix, element, suffix]
          |> Enum.reject(&is_nil/1)
          |> Kino.Layout.grid()
        end

        ## Events handlers

        defp step_one(%{data: %{name: name}}, state) do
          if name == "" do
            %{state | name: name, error: "name can't be blank"}
          else
            %{state | name: name, page: 2}
          end
        end

        defp step_two(%{data: %{address: address}}, state) do
          if address == "" do
            %{state | address: address, error: "address can't be blank"}
          else
            %{state | address: address, page: 2}
          end
        end

        defp go_back(_, state) do
          %{state | page: state.page - 1}
        end
      end

      Kino.Screen.new(MyScreen, :ok)
  """

  defmodule Server do
    @moduledoc false

    use GenServer

    def start_link(mod_frame_state) do
      GenServer.start_link(__MODULE__, mod_frame_state)
    end

    def control(from, fun) when is_function(fun, 2) do
      Kino.Control.subscribe(from, {__MODULE__, fun})
      from
    end

    @impl true
    def init({module, frame, state}) do
      {:ok, state} = module.init(state)
      {:ok, render(module, frame, nil, state)}
    end

    @impl true
    def handle_info({{__MODULE__, fun}, data}, {module, frame, client_id, state}) do
      state = fun.(data, state)
      {:noreply, render(module, frame, client_id, state)}
    end

    defp render(module, frame, client_id, state) do
      Kino.Frame.render(frame, module.render(state), to: client_id)
      {module, frame, client_id, state}
    end
  end

  @typedoc "The state of the screen"
  @type state :: term()

  @doc """
  Callback invoked when the screen is initialized.

  It receives the second argument given to `new/2` and
  it must return the screen state.
  """
  @callback init(state) :: {:ok, state}

  @doc """
  Callback invoked to render the screen, whenever there
  is a control event.

  It receives the state and it must return a renderable output.
  """
  @callback render(state) :: term()

  @doc """
  Receives a control or an input and invokes the given 2-arity function
  once its actions are triggered.
  """

  @spec control(element, (map(), state() -> state())) :: element
        when element: Kino.Control.t() | Kino.Input.t()
  defdelegate control(element, fun), to: Server

  def new(module, state) when is_atom(module) do
    frame = Kino.Frame.new()
    {:ok, _pid} = Kino.start_child({Server, {module, frame, state}})
    frame
  end
end

```

```elixir
defmodule MyScreen do
  @behaviour Kino.Screen

  # Import Kino.Control for forms, Kino.Input for inputs, and Screen for control/2
  import Kino.{Control, Input, Screen}

  def init(results_frame) do
    {:ok, %{selection: :name, frame: results_frame}}
  end

  # A form to search by name...
  def render(%{selection: :name} = state) do
    form(
      [name: text("Name")],
      submit: "Search"
    )
    |> control(&by_name/2)
    |> add_layout(state)
  end

  # A form to search by address...
  def render(%{selection: :address} = state) do
    form(
      [address: text("Address")],
      submit: "Search"
    )
    |> control(&by_address/2)
    |> add_layout(state)
  end

  # Here we add the select
  defp add_layout(element, state) do
    select =
      select("Search by", [name: "Name", address: "Address"], default: state.selection)
      |> control(&selection/2)

    Kino.Layout.grid([select, element, state.frame])
  end

  ## Events handlers

  defp selection(%{value: selection}, state) do
    %{state | selection: selection}
  end

  # On the search, you can either add new clauses
  defp by_name(%{data: %{name: name}}, state) do
    Kino.Frame.render(state.frame, "SEARCHING BY NAME: #{name}")
    state
  end

  defp by_address(%{data: %{address: address}}, state) do
    Kino.Frame.render(state.frame, "SEARCHING BY ADDRESS: #{address}")
    state
  end
end

results_frame = Kino.Frame.new()
Kino.Screen.new(MyScreen, results_frame)

@josevalim josevalim mentioned this pull request Feb 10, 2025
end

def new(module, state) when is_atom(module) do
{:ok, state} = module.init(state)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jonatanklosko, this may be a bit confusing.

  1. We call module.init/1 in the caller process, so we don't fall into the trap of starting a frame inside a process
  2. The first render/1 happens in the watcher process
  3. All following render happens in the user-specific process

We are exposing three processes to users. Given when module.init/1 is called today, I am thinking we should just get rid of it and you pass the state as argument to Kino.Screen.new directly. We may add it back in the future to customize the user specific process instead.

@hugobarauna
Copy link
Member

@josevalim as we talked, I like this direction. I built this dynamic form based on this PR:

CleanShot.2025-02-11.at.17.10.19.mp4

Here's the source code:

defmodule MyForm do
  @behaviour Kino.Screen
  import Kino.{Control, Input, Layout, Screen}

  # Constants
  @country_options [empty: "", usa: "USA", can: "Canada"]
  @language_options %{
    usa: [empty: "", en: "English"],
    can: [empty: "", en: "English", fr: "French"]
  }

  @impl true
  def render(state), do: build_form(state)

  # Form Building
  defp build_form(%{values: values} = state) do
    fields = form_fields(values)

    form(fields, report_changes: true, submit: "Submit")
    |> control(&handle_event/2)
    |> add_layout(state)
  end

  defp form_fields(form_values) when map_size(form_values) == 0 do
    [country: country_select()]
  end

  defp form_fields(%{country: selected_country, language: selected_language}) do
    [
      country: country_select(selected_country),
      language: language_select(selected_country, selected_language)
    ]
  end

  defp form_fields(%{country: selected_country}) do
    [
      country: country_select(selected_country),
      language: language_select(selected_country)
    ]
  end

  # Form Controls
  defp country_select(selected \\ :empty) do
    select("Country", @country_options, default: selected)
  end

  defp language_select(country, selected \\ :empty) do
    language_options = Map.get(@language_options, country)
    select("Language", language_options, default: selected)
  end

  # Event Handling
  defp handle_event(%{data: %{country: :empty}, type: :change}, state) do
    Kino.Frame.append(state.debug_frame, "[event] country selected: empty")
    Kino.Frame.clear(state.frame)
    %{state | values: %{}}
  end

  defp handle_event(
         %{data: %{country: _country, language: :empty}, type: :change} = event,
         state
       ) do
    Kino.Frame.append(state.debug_frame, "[event] language selected: empty")
    Kino.Frame.clear(state.frame)
    %{state | values: event.data}
  end

  defp handle_event(
         %{data: %{country: country, language: language}, type: :change} = event,
         state
       ) do
    language_options = Map.get(@language_options, country)
    language_text = Keyword.get(language_options, language)
    Kino.Frame.append(state.debug_frame, "[event] language selected #{language_text}")
    %{state | values: event.data}
  end

  defp handle_event(%{data: %{country: country}, type: :change} = event, state) do
    country_text = Keyword.get(@country_options, country)
    Kino.Frame.append(state.debug_frame, "[event] country selected: #{country_text}")
    %{state | values: event.data}
  end

  defp handle_event(
         %{data: %{country: country, language: language}, type: :submit} = event,
         state
       ) do
    render_submission(state.frame, country, language)
    %{state | values: event.data}
  end

  # Helpers
  defp add_layout(element, state) do
    grid([element, state.frame, state.debug_frame])
  end

  defp render_submission(frame, country, language) do
    country_text = Keyword.get(@country_options, country)
    language_options = Map.get(@language_options, country)
    language_text = Keyword.get(language_options, language)

    Kino.Frame.render(
      frame,
      Kino.Markdown.new("""
      Form submission:
        - Country: #{country_text}
        - Language: #{language_text}
      """)
    )
  end
end

init_state = %{
  frame: Kino.Frame.new(placeholder: false),
  debug_frame: Kino.Frame.new(placeholder: false),
  values: %{}
}

Kino.Screen.new(MyForm, init_state)

This already better that doing all of the event subscribing by myself, like I did here.

But, still feels kinda verbose to build a dynamic form with just two fields.

I don't know if there would be a better way, but it would be nice to try.

@josevalim
Copy link
Contributor Author

I have updated the docs to include your dynamic form example.

end

def handle_event(%{data: data, type: :change}, state) do
%{state | data: Map.merge(@defaults, data)}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jonatanklosko we could remove this merge if we allow a form to have an input set to nil. In that case, the input is not rendered by the data comes back set as nil. Then I can change the default value to nil: "" and streamline this code a bit more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see how that would be convenient, but it does feel weird outside of this context. My main concern though, is that it would crash older Livebook versions. Perhaps we could do some special casing and transform the event before broadcasting in Kino, but I am not a fan of that either :<

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jonatanklosko I think it could be used in other contexts, basically every time you need to build a form dynamically and some fields may be missing, we need to do pruning and then, when accessing the data, use Map.get(data, field) for those fields.

end

def handle_event(%{data: data, type: :change}, state) do
%{state | data: Map.merge(@defaults, data)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see how that would be convenient, but it does feel weird outside of this context. My main concern though, is that it would crash older Livebook versions. Perhaps we could do some special casing and transform the event before broadcasting in Kino, but I am not a fan of that either :<

@jonatanklosko
Copy link
Member

@josevalim lgtm! We are only missing tests. If you prefer me to add some, let me know :)

@hugobarauna
Copy link
Member

@josevalim I'm satisfied with the API and abstraction level. I even asked Claude if we could build a Kino.DynamicForm abstraction on top of Kino.Screen and it doesn't look like it added much more help at this point.

So for me it's :shipit:

Comment on lines +68 to +69
send(watcher, {:client_leave, "unknown_client1"})
send(watcher, {:client_leave, "client1"})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jonatanklosko those events are emitted by Kino.Bridge after monitoring objects. Is there a way to have the bridge send those or do I have to emulate them by sending it directly to the watcher?

Copy link
Member

@jonatanklosko jonatanklosko Feb 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message is sent from the evaluator, so for tests sending directly is the way to go. Similarly, we send control events directly, because they are sent from LV directly to the destination.

We could have some conveniences in Kino.Test, but I usually wait until we need those in other packages, so that we don't end up with unnecessarily large API there :)


assert ExUnit.CaptureLog.capture_log(fn ->
# Click the button and make it fail
info = %{origin: "client1", type: :fail_event}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally I would stick to events format used in practice. So instead of :type here, we could render a grid with three buttons, wdyt? Shouldn't be much more code.

import Kino.Control

defmodule MyScreen do
def new(state) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's name it render_fun to make it more clear?

end
end

defp control(button, parent) do
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's preference, but I would move private functions to the end of the module :D

@josevalim josevalim merged commit 7dbc5f5 into main Feb 23, 2025
1 check passed
@josevalim
Copy link
Contributor Author

💚 💙 💜 💛 ❤️

@josevalim josevalim deleted the jv-kino-screen branch February 23, 2025 15:06
adiibanez pushed a commit to adiibanez/kino that referenced this pull request Feb 27, 2025
Co-authored-by: Hugo Baraúna <[email protected]>
Co-authored-by: Jonatan Kłosko <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants