Kino : construire une interface dans Livebook
Section
Retour vers le sommaire des tips Accueil
Mix.install([{:kino, "~> 0.19.0"}])
Une seule idée à comprendre
Kino offre une trentaine de composants, et on peut passer une heure à les essayer un par un sans rien comprendre. Il y a en réalité deux façons de lire un contrôle, et tout le reste en découle :
| Tirer (pull) | Pousser (push) | |
|---|---|---|
| Quoi | Kino.Input.* + Kino.Input.read/1 |
Kino.Control.* + Kino.listen/2 |
| Quand la valeur est lue | quand on réexécute la cellule | quand l'utilisateur agit |
| Qui exécute le code | le processus d'évaluation de la cellule | un processus lancé par Kino.listen |
| Pour quoi faire | un paramètre de notebook | une interface qui réagit |
Un notebook d'analyse se contente du premier. Une interface — un formulaire, un bouton, un tableau qui se rafraîchit — a besoin du second. Les mélanger est l'erreur qui fait perdre le plus de temps ; la section « Le piège » plus bas montre le message d'erreur exact que ça produit.
1. Les entrées : Kino.Input
Une entrée se construit par son type. Il n'y a pas de Kino.Input.new : chaque type a sa fonction.
| Fonction | Valeur lue |
|---|---|
text/2, textarea/2, password/2 |
une chaîne |
url/2 |
une chaîne d'URL valide, ou nil |
number/2 |
un nombre, ou nil |
range/2 |
un flottant, entre :min et :max |
checkbox/2 |
true ou false |
select/3 |
une des valeurs proposées |
date/2, utc_time/2, utc_datetime/2 |
une Date, Time, DateTime |
color/2 |
une chaîne "#rrggbb" |
file/2, image/2, audio/2 |
une référence (voir Kino.Input.file_path/1) |
Le premier argument est toujours l'étiquette affichée. Le reste passe par les options.
2. Une entrée, cellule par cellule
La cellule ci-dessous définit l'entrée. Comme elle est la dernière expression, Livebook l'affiche.
prenom = Kino.Input.text("Prénom", default: "Ada")
Celle-ci la lit. Modifiez le champ ci-dessus, puis réexécutez cette cellule-ci : la valeur suit.
Kino.Input.read(prenom)
C'est tout le modèle « tirer » : l'entrée vit dans une cellule, sa valeur se lit dans une autre, à chaque réexécution.
Trois autres types, pour voir les options les plus utiles :
# :min, :max et :step définissent la course du curseur
tolerance = Kino.Input.range("Tolérance", min: 0, max: 1, step: 0.05, default: 0.25)
# les options d'un select sont des couples {valeur, étiquette}
langue = Kino.Input.select("Langue", [{:fr, "Français"}, {:en, "Anglais"}])
# une case à cocher vaut false par défaut
detail = Kino.Input.checkbox("Afficher le détail")
# plusieurs sorties dans une même cellule : Kino.render pour les précédentes
Kino.render(tolerance)
Kino.render(langue)
detail
%{
tolerance: Kino.Input.read(tolerance),
langue: Kino.Input.read(langue),
detail: Kino.Input.read(detail)
}
3. Le piège : lire une entrée jamais affichée
Une entrée construite mais jamais rendue n'existe pas côté navigateur, et sa lecture échoue :
# cette entrée n'est pas la dernière expression de la cellule : elle n'est pas affichée
_fantome = Kino.Input.text("Jamais affichée")
Kino.Input.read(_fantome)
** (RuntimeError) failed to read input value, input not found. Make sure to render the input before reading its value
Second piège, plus subtil, et c'est la raison d'être de la seconde moitié de ce notebook — lire une entrée depuis un autre processus :
** (RuntimeError) input value can only be read in the main evaluation process,
but Kino.Input.read/1 was called by another process. (...) In case you want to read
the latest input value from a long-running process, consider using Kino.Control.form/2,
or subscribing to the input change using one of the functions in the Kino.Control module
Le message dit exactement quoi faire : dès qu'un processus vit plus longtemps que la cellule — donc dès qu'on construit une interface — on passe à Kino.Control.
4. Les contrôles événementiels : Kino.Control
Trois sources d'événements suffisent pour presque tout :
bouton = Kino.Control.button("Dire bonjour")
# Kino.listen démarre un processus et rend son pid : la cellule ne bloque pas
Kino.listen(bouton, fn evenement ->
IO.inspect(evenement, label: "clic")
end)
# le contrôle en dernier, pour qu'il soit la sortie affichée
bouton
Cliquez sur le bouton : chaque clic imprime %{origin: "...", type: :click}. Aucune cellule n'est réexécutée.
La règle à retenir dès maintenant : un contrôle et son écouteur vivent dans la même cellule.
Kino.Control.button/1appelleKino.Bridge.reference_object(ref, self()): le contrôle est rattaché au processus d'évaluation de sa cellule. Séparer les deux donne un notebook qui marche au premier passage puis, après une réexécution, un bouton qui ne déclenche plus rien — sans le moindre message. La section 5 montre la variante bruyante du même problème.
Voici la forme exacte des événements, par source :
| Source | Événement reçu |
|---|---|
Kino.Control.button/1 |
%{type: :click, origin: _} |
Kino.Control.form/2 |
%{type: :submit, data: %{champ: valeur}, origin: _} |
Kino.Control.interval/1 |
%{type: :interval, iteration: 0, 1, 2…} |
une Kino.Input dans un flux |
%{type: :change, value: _, origin: _} |
Kino.Control.keyboard/2 |
%{type: :keydown, key: "k", origin: _} |
origin identifie le client : deux personnes ouvrant le même notebook produisent deux valeurs différentes. C'est ce qui permet de répondre à un seul utilisateur.
Le formulaire regroupe plusieurs entrées et n'émet qu'à la validation — ses champs sont des couples {clé, entrée}, et la clé se retrouve dans data :
formulaire =
Kino.Control.form(
[
nom: Kino.Input.text("Nom"),
urgent: Kino.Input.checkbox("Urgent")
],
submit: "Envoyer"
)
Kino.listen(formulaire, fn %{data: donnees, origin: _} ->
IO.inspect(donnees, label: "reçu")
end)
formulaire
Les entrées d'un formulaire se lisent dans l'événement, jamais avec Kino.Input.read/1 : c'est précisément le cas que le message d'erreur de la section 3 décrit.
5. Mettre à jour l'affichage : Kino.Frame
Un Kino.Frame est une zone réservée dans la sortie d'une cellule, qu'un autre processus peut redessiner plus tard.
La zone et son écouteur doivent tenir dans la même cellule. C'est la règle la plus importante de ce notebook, et la raison vient de la doc de Kino.start_child/1, sur lequel Kino.Frame.new/0 repose :
The process is automatically terminated when the current process terminates or the current cell reevaluates.
Une zone est donc un processus attaché à la cellule qui l'a créée. Si la zone vit dans une cellule et l'écouteur dans une autre, réexécuter celle de la zone tue son processus — pendant que l'écouteur, lui, survit et continue d'écrire dans un pid mort :
** (exit) exited in: GenServer.call(#PID<0.711.0>, {:render, %Kino.Markdown{...}, :default}, :infinity)
** (EXIT) no process: the process is not alive or there's no process currently associated with the given name
Les garder ensemble règle le problème : réexécuter la cellule recrée la zone et l'écouteur d'un seul coup.
compteur = Kino.Control.button("Compter")
zone = Kino.Frame.new()
Kino.listen(compteur, 0, fn _evenement, n ->
# render remplace le contenu, append l'ajoute à la suite
Kino.Frame.render(zone, Kino.Markdown.new("**#{n + 1}** clic(s)"))
{:cont, n + 1}
end)
# la disposition en dernier : Kino.listen, lui, rend un pid
Kino.Layout.grid([compteur, zone], boxed: true)
Kino.listen/3 prend un état initial et une fonction à deux arguments qui rend {:cont, nouvel_état} — c'est l'équivalent d'un GenServer en trois lignes, sans en écrire un. Ici l'état est le compteur de clics, et il survit d'un clic à l'autre sans variable globale.
Les trois opérations d'une zone :
| Fonction | Effet |
|---|---|
Kino.Frame.render(zone, contenu) |
remplace tout le contenu |
Kino.Frame.append(zone, contenu) |
ajoute à la suite |
Kino.Frame.clear(zone) |
vide la zone |
render/3 et append/3 acceptent to: origin pour n'afficher que chez un client — la base d'une interface multi-utilisateur.
6. Disposer les éléments : Kino.Layout
Kino.Layout.grid(
[
Kino.Markdown.new("### Gauche"),
Kino.Markdown.new("### Droite")
],
columns: 2,
boxed: true
)
Kino.Layout.tabs(
Résumé: Kino.Markdown.new("Trois lignes de résumé."),
Données: Kino.DataTable.new([%{ville: "Nantes", habitants: 320_000}])
)
7. Les sorties les plus utiles
Kino.render(Kino.Markdown.new("Du **Markdown**, avec du `code`."))
Kino.render(Kino.Text.new("Du texte brut, monospace."))
# un arbre repliable : bien plus lisible qu'IO.inspect sur une structure imbriquée
Kino.render(Kino.Tree.new(%{a: [1, 2, %{b: :profond}], c: {:ok, "fin"}}))
# un tableau triable, à partir de n'importe quoi qui implémente Table.Reader
Kino.render(Kino.DataTable.new([
%{ville: "Nantes", habitants: 320_000},
%{ville: "Rennes", habitants: 220_000}
]))
Kino.Mermaid.new("""
graph LR
Entrée --> Contrôle --> Frame
""")
Kino.Download.new(fn -> "contenu" end, filename: "export.txt") produit un bouton de téléchargement, et Kino.Image, Kino.Audio, Kino.Video, Kino.HTML font ce que leur nom indique.
8. La version courte : Kino.Shorts
Kino.Shorts regroupe les mêmes constructeurs sous des noms courts, à importer une fois :
import Kino.Shorts
markdown("Un titre en **une ligne**")
Attention aux fonctions read_* : elles ne bloquent pas et n'attendent personne. read_text("Nom") fait littéralement Kino.Input.text("Nom") |> Kino.render() |> Kino.Input.read() — au premier passage, elle rend donc la valeur par défaut. C'est l'affichage et la lecture dans une seule cellule, à réexécuter pour obtenir la saisie.
# affiche le champ, et rend "" tant que la cellule n'a pas été réexécutée
read_text("Votre nom")
9. Tout assembler : un mini tableau de bord
Vingt lignes, et les quatre briques y sont : un formulaire, une zone, un état, une disposition.
recherche =
Kino.Control.form([terme: Kino.Input.text("Filtrer par ville")], submit: "Chercher")
resultats = Kino.Frame.new()
villes = [
%{ville: "Nantes", habitants: 320_000},
%{ville: "Rennes", habitants: 220_000},
%{ville: "Angers", habitants: 155_000}
]
Kino.listen(recherche, fn %{data: %{terme: terme}} ->
filtrees =
Enum.filter(villes, &String.contains?(String.downcase(&1.ville), String.downcase(terme)))
contenu =
case filtrees do
[] -> Kino.Markdown.new("_Aucune ville pour **#{terme}**._")
lignes -> Kino.DataTable.new(lignes, name: "Résultats")
end
Kino.Frame.render(resultats, contenu)
end)
# la disposition doit être la dernière expression : Kino.listen, lui, rend un pid
Kino.Layout.grid([recherche, resultats], boxed: true)
Pour aller plus loin
Kino.Process.render_sup_tree/1dessine un arbre de supervision vivant — à essayer juste après la note GenServer & Supervisor.Kino.Control.keyboard/2capte les touches,Kino.Control.interval/1produit un tic périodique, etKino.Control.stream/1fusionne plusieurs sources en un seul flux.Kino.JSetKino.JS.Livepermettent d'écrire un composant en JavaScript,Kino.SmartCellune cellule intelligente avec son propre formulaire.- La version « appliquée » de ce notebook, qui interroge une vraie API : Un outil interne en trente lignes.
À retenir
- Deux modes, et un seul choix à faire : tirer (
Kino.Input.read/1, valeur lue à la réexécution de la cellule) ou pousser (Kino.Control+Kino.listen/2, code exécuté quand l'utilisateur agit). - Une entrée doit être affichée pour être lisible ; sinon
Kino.Input.read/1lèveinput not found. - Une entrée ne se lit pas depuis un autre processus : dans ce cas,
Kino.Control.form/2et la valeur arrive dans l'événement. Kino.listen/2rend un pid et ne bloque pas ;Kino.listen/3ajoute un état et attend{:cont, état}.- Un contrôle, sa zone et son écouteur tiennent dans une seule cellule : les deux sont attachés au processus d'évaluation de leur cellule et meurent à sa réexécution. Séparés, on récolte soit un bouton devenu inerte en silence, soit un
(EXIT) no processsur la zone. Kino.Frameest la seule façon de changer une sortie sans réexécuter la cellule ;renderremplace,appendajoute, etto: originvise un seul client.- La dernière expression d'une cellule est affichée automatiquement ; pour en afficher plusieurs,
Kino.render/1. Kino.Shorts.read_*rend et lit dans la foulée : au premier passage, c'est la valeur par défaut.- Documentation officielle : https://hexdocs.pm/kino/Kino.html