implement webc context protocol for lustre

This commit is contained in:
Brent Schroeter 2025-07-22 00:21:54 -07:00
parent 389bd27b33
commit 1d95b6f917
14 changed files with 318 additions and 60 deletions

View file

@ -1,6 +0,0 @@
import field_adder
pub fn main() -> Nil {
let assert Ok(_) = field_adder.register()
Nil
}

View file

@ -1,13 +1,12 @@
{% extends "base.html" %}
{% block main %}
<script type="module" src="{{ settings.root_path }}/js_dist/cells.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/lens-controls.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-components.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer-controller.mjs"></script>
<script type="module" src="{{ settings.root_path }}/glm_dist/field_adder.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/field_adder_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/viewer_controller_component.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/cell_text_component.mjs"></script>
<link rel="stylesheet" href="{{ settings.root_path }}/viewer.css">
<table class="viewer">
<viewer-controller root-path="{{ settings.root_path }}">
<table class="viewer">
<thead>
<tr>
{% for field in fields %}
@ -45,5 +44,6 @@
</tr>
{% endfor %}
</tbody>
</table>
</table>
</viewer-controller>
{% endblock %}

View file

@ -16,17 +16,12 @@ run = "cargo run serve"
description = "Run the server. For development: `mise watch --restart serve`."
sources = ["**/*.rs", "**/*.html"]
[tasks.vite]
run = "deno task build"
dir = "./components"
sources = ["**/*.ts"]
[tasks.build-js]
run = "sh build.sh"
dir = "./webc"
sources = ["webc/src/**/*.gleam", "webc/src/**/*.mjs"]
[tasks.lustre]
run = "gleam run -m lustre/dev build component field_adder --outdir=../glm_dist"
dir = "./glm"
sources = ["**/*.gleam"]
[tasks.sass]
[tasks.build-css]
run = "sass sass/:css_dist/"
sources = ["sass/**/*.scss"]

View file

5
webc/build.sh Normal file
View file

@ -0,0 +1,5 @@
# Compile each .gleam file in src/ as a separate component module.
ls src | \
grep '_component.gleam' | \
xargs -I {} basename {} .gleam | \
xargs -I {} gleam run -m lustre/dev build component {} --outdir=../js_dist

View file

@ -1,4 +1,4 @@
name = "glm"
name = "webc"
version = "1.0.0"
target = "javascript"

View file

@ -0,0 +1,88 @@
import gleam/dynamic
import gleam/dynamic/decode
import lustre.{type App}
import lustre/attribute as attr
import lustre/component
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import context
pub const name: String = "cell-text"
pub fn component() -> App(Nil, Model, Msg) {
lustre.component(init, update, view, [
component.on_attribute_change("value", fn(value) {
ParentChangedValue(value) |> Ok
}),
component.on_attribute_change("column", fn(value) {
ParentChangedColumn(value) |> Ok
}),
component.on_attribute_change("selected", fn(value) {
ParentChangedSelected(value != "") |> Ok
}),
component.on_attribute_change("editing", fn(value) {
ParentChangedEditing(value != "") |> Ok
}),
component.on_property_change("pkeys", {
decode.list(of: decode.string) |> decode.map(ParentChangedPkeys)
}),
])
}
pub type Model {
Model(
root_path: String,
column: String,
pkeys: List(String),
selected: Bool,
editing: Bool,
)
}
fn init(_) -> #(Model, Effect(Msg)) {
#(
Model(
root_path: "",
column: "",
pkeys: [""],
selected: False,
editing: False,
),
context.request_context(
context: dynamic.string("root_path"),
subscribe: False,
decoder: decode.string |> decode.map(AncestorChangedRootPath),
),
)
}
pub type Msg {
AncestorChangedRootPath(String)
ParentChangedColumn(String)
ParentChangedEditing(Bool)
ParentChangedValue(String)
ParentChangedSelected(Bool)
ParentChangedPkeys(List(String))
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
AncestorChangedRootPath(root_path) -> #(
Model(..model, root_path:),
effect.none(),
)
_ -> #(model, effect.none())
}
}
fn view(model: Model) -> Element(Msg) {
element.fragment([
html.link([
attr.rel("stylesheet"),
attr.href(model.root_path <> "/css_dist/cell_text/index.css"),
]),
html.div([], [html.text(model.root_path)]),
])
}

17
webc/src/context.ffi.mjs Normal file
View file

@ -0,0 +1,17 @@
class ContextRequestEvent extends Event {
constructor(context, subscribe, callback) {
super("context-request", { bubbles: true, composed: true });
this.context = context;
this.subscribe = subscribe;
this.callback = callback;
}
}
export function emitContextRequest(root, context, subscribe, callback) {
root.dispatchEvent(new ContextRequestEvent(context, subscribe, callback));
}
// FFI shim so that Gleam can pass around Javascript functions as dynamic values
export function callCallback(callback, value) {
callback(value);
}

93
webc/src/context.gleam Normal file
View file

@ -0,0 +1,93 @@
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode.{type Decoder}
import gleam/option.{type Option}
import lustre/attribute.{type Attribute}
import lustre/effect.{type Effect}
import lustre/event
/// WARNING: Lifecycle hooks in Lustre are currently limited to non-existent,
/// so it's not possible to unsubscribe from context updates on component
/// teardown. Be cautious using this effect with components that are not
/// expected to be permanent fixtures on a page. (Refer to
/// https://github.com/lustre-labs/lustre/issues/320.)
pub fn request_context(
context context: Dynamic,
subscribe subscribe: Bool,
decoder decoder: Decoder(msg),
) -> Effect(msg) {
use dispatch, root <- effect.before_paint
use value, _unsubscribe <- emit_context_request(root, context, subscribe)
case decode.run(value, decoder) {
Ok(msg) -> {
dispatch(msg)
}
Error(_) -> Nil
}
Nil
}
@external(javascript, "./context.ffi.mjs", "emitContextRequest")
fn emit_context_request(
root _root: Dynamic,
context _context: Dynamic,
subscribe _subscribe: Bool,
callback _callback: fn(Dynamic, Option(fn() -> Nil)) -> Nil,
) -> Nil {
Nil
}
/// Capture "context-request" events querying a particular context type.
/// Intended to be used in a context provider, on a child element near the
/// component root.
pub fn on_context_request(
context match_context: Dynamic,
handler handler: fn(Dynamic, Bool) -> msg,
) -> Attribute(msg) {
event.advanced("context-request", {
use ev_context <- decode.field("context", decode.dynamic)
use subscribe <- decode.field("subscribe", decode.bool)
use callback <- decode.field("callback", decode.dynamic)
case ev_context == match_context {
True -> {
decode.success(event.handler(
handler(callback, subscribe),
False,
ev_context == match_context,
))
}
False ->
// returning a DecodeError seems like the only way to not trigger a message dispatch
decode.failure(
event.handler(handler(callback, subscribe), False, False),
"Context",
)
}
})
}
/// Effect for passing context value back to consumers who have requested it.
pub fn update_consumers(
callbacks callbacks: List(Dynamic),
value value: Dynamic,
) -> Effect(msg) {
use _dispatch <- effect.from
do_update_consumers(callbacks:, value:)
}
fn do_update_consumers(
callbacks callbacks: List(Dynamic),
value value: Dynamic,
) -> Nil {
case callbacks {
[] -> Nil
[cb, ..rest] -> {
call_callback(callback: cb, value:)
do_update_consumers(rest, value)
}
}
}
@external(javascript, "./context.ffi.mjs", "callCallback")
fn call_callback(callback _callback: Dynamic, value _value: Dynamic) -> Nil {
Nil
}

View file

@ -0,0 +1,66 @@
import gleam/dynamic.{type Dynamic}
import lustre.{type App}
import lustre/component
import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
import context
pub const name: String = "viewer-controller"
pub fn component() -> App(Nil, Model, Msg) {
lustre.component(init, update, view, [
component.on_attribute_change("root-path", fn(value) {
ParentChangedRootPath(value) |> Ok
}),
])
}
pub type Model {
Model(root_path: String, root_path_consumers: List(Dynamic))
}
fn init(_) -> #(Model, Effect(Msg)) {
#(Model(root_path: "", root_path_consumers: []), effect.none())
}
pub type Msg {
ParentChangedRootPath(String)
ChildRequestedRootPath(Dynamic, Bool)
}
fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
ParentChangedRootPath(root_path) -> #(
Model(..model, root_path:),
context.update_consumers(
model.root_path_consumers,
dynamic.string(root_path),
),
)
ChildRequestedRootPath(callback, subscribe) -> #(
case subscribe {
True ->
Model(..model, root_path_consumers: [
callback,
..model.root_path_consumers
])
False -> model
},
context.update_consumers([callback], dynamic.string(model.root_path)),
)
}
}
fn view(_: Model) -> Element(Msg) {
html.div(
[
context.on_context_request(
dynamic.string("root_path"),
ChildRequestedRootPath,
),
],
[component.default_slot([], [])],
)
}