···4242let make url =
4343 let effective_url = make_effective_url url in
4444 let client = Jtw.create effective_url in
4545- { client; url; init_config = None; on_message_cb = (fun _ -> ()) }
4545+ let t = { client; url; init_config = None; on_message_cb = (fun _ -> ()) } in
4646+ (* Wire widget rendering: forward widget messages from the worker to the
4747+ DOM renderer, and send widget events back to the worker. *)
4848+ let send_fn ~widget_id ~handler_id ~event_type ~value =
4949+ Jtw.send_widget_event client ~widget_id ~handler_id ~event_type ~value
5050+ in
5151+ Jtw.set_on_widget_update client (fun widget_id view_any ->
5252+ (* view_any is Js.Unsafe.any — coerce to Jv.t (they are the same repr) *)
5353+ let view_jv : Jv.t = Obj.magic view_any in
5454+ Widget_render.update_widget ~send:send_fn widget_id view_jv);
5555+ Jtw.set_on_widget_clear client (fun widget_id ->
5656+ Widget_render.clear_widget widget_id);
5757+ Jtw.set_on_widget_config client (fun widget_id config ->
5858+ Widget_render.config_widget widget_id config);
5959+ Jtw.set_on_widget_command client (fun widget_id cmd data ->
6060+ Widget_render.command_widget widget_id cmd data);
6161+ Jtw.set_on_widget_register_adapter client (fun kind js_code ->
6262+ Widget_render.register_js_adapter ~send:send_fn kind js_code);
6363+ t
46644765let on_message t fn = t.on_message_cb <- fn
4866···165183let post t (req : X_protocol.request) =
166184 match req with
167185 | X_protocol.Eval (id, _line_number, code) ->
186186+ (* Tell widget_render which cell is executing so widgets are placed
187187+ right after this cell's <x-ocaml> element in the DOM. *)
188188+ let doc = Brr.Document.to_jv Brr.G.document in
189189+ let cells = Jv.call doc "querySelectorAll" [| Jv.of_string "x-ocaml" |] in
190190+ let cell_el = Jv.call cells "item" [| Jv.of_int id |] in
191191+ if not (Jv.is_null cell_el) then
192192+ Widget_render.set_active_cell cell_el;
168193 let stream = Jtw.eval_stream t.client code in
169194 Lwt.async (fun () ->
170195 Lwt.catch (fun () ->
+247
src/widget_render.ml
···11+(** Widget renderer for x-ocaml.
22+33+ Renders view node JSON (from js_top_worker widget protocol) into real DOM
44+ elements using Brr, and wires event handlers back to the worker.
55+66+ Supports two kinds of widgets:
77+ - Element/Text views: declarative DOM trees, fully replaced on each update
88+ - Managed widgets: delegate to registered adapters (e.g. Leaflet maps) that
99+ manage their own DOM and respond to config updates and commands *)
1010+1111+open Brr
1212+1313+(** Type alias for the function that sends widget events back to the worker. *)
1414+type send_fn =
1515+ widget_id:string -> handler_id:string ->
1616+ event_type:string -> value:string option -> unit
1717+1818+(** A managed widget adapter. Registered client-side per [kind].
1919+ All functions receive and return raw Jv.t values (JS objects). *)
2020+type adapter = {
2121+ create : Jv.t -> string -> send_fn -> Jv.t;
2222+ (** [create container config send] creates the widget and returns adapter state *)
2323+ update : Jv.t -> string -> unit;
2424+ (** [update state config] reconciles a config change *)
2525+ command : Jv.t -> string -> string -> unit;
2626+ (** [command state cmd data] handles an imperative command *)
2727+ destroy : Jv.t -> unit;
2828+ (** [destroy state] cleans up *)
2929+}
3030+3131+(** Global adapter registry: kind -> adapter *)
3232+let adapters : (string, adapter) Hashtbl.t = Hashtbl.create 8
3333+3434+(** Register an adapter for the given [kind] string. *)
3535+let register_adapter kind adapter =
3636+ Hashtbl.replace adapters kind adapter
3737+3838+(** Register an adapter from JavaScript code.
3939+ The JS must be an IIFE returning [{create, update, command, destroy}].
4040+ [send] in JS is [send(handler_id, value_string)]. *)
4141+let register_js_adapter ~(send : send_fn) kind js_code =
4242+ let obj = Jv.call Jv.global "eval" [| Jv.of_string js_code |] in
4343+ let adapter = {
4444+ create = (fun container_jv config_str send_fn ->
4545+ let js_send = Jv.repr (fun handler_id value ->
4646+ let hid = Jv.to_string handler_id in
4747+ let v =
4848+ if Jv.is_null value || Jv.is_undefined value then None
4949+ else Some (Jv.to_string value)
5050+ in
5151+ send_fn ~widget_id:"" ~handler_id:hid ~event_type:hid ~value:v
5252+ ) in
5353+ Jv.call obj "create" [| container_jv; Jv.of_string config_str; js_send |]);
5454+ update = (fun state config_str ->
5555+ Jv.call obj "update" [| state; Jv.of_string config_str |] |> ignore);
5656+ command = (fun state cmd data ->
5757+ Jv.call obj "command" [| state; Jv.of_string cmd; Jv.of_string data |] |> ignore);
5858+ destroy = (fun state ->
5959+ Jv.call obj "destroy" [| state |] |> ignore);
6060+ } in
6161+ ignore send; (* send is captured by the adapter's create wrapper at call time *)
6262+ Hashtbl.replace adapters kind adapter
6363+6464+(** Per-widget state *)
6565+type widget_entry = {
6666+ container : El.t;
6767+ widget_id : string;
6868+ managed : (string * Jv.t) option;
6969+ (** For managed widgets: (kind, adapter_state) *)
7070+}
7171+7272+(** Global registry of active widgets *)
7373+let widgets : (string, widget_entry) Hashtbl.t = Hashtbl.create 16
7474+7575+(** The current anchor element — new widget containers are inserted after this.
7676+ Set by [set_active_cell] before each cell eval begins. *)
7777+let active_cell : Jv.t option ref = ref None
7878+7979+(** Set the currently active cell element. Call this before each eval so that
8080+ any widgets created during that eval are placed right after the cell. *)
8181+let set_active_cell (el : Jv.t) = active_cell := Some el
8282+8383+(** Recursively render a view node JSON object to a DOM element.
8484+ [send] is called when an event handler fires. *)
8585+let rec render_node ~widget_id ~(send : send_fn) (node : Jv.t) : El.t =
8686+ let t = Jv.to_string (Jv.get node "t") in
8787+ match t with
8888+ | "txt" ->
8989+ let v = Jv.to_string (Jv.get node "v") in
9090+ El.span [ El.txt' v ]
9191+ | "el" ->
9292+ let tag = Jv.to_string (Jv.get node "tag") in
9393+ let attrs_arr =
9494+ let a = Jv.get node "a" in
9595+ if Jv.is_none a || Jv.is_undefined a then [||]
9696+ else Jv.to_jv_array a
9797+ in
9898+ let children_arr =
9999+ let c = Jv.get node "c" in
100100+ if Jv.is_none c || Jv.is_undefined c then [||]
101101+ else Jv.to_jv_array c
102102+ in
103103+ let el = El.v (Jstr.v tag) [] in
104104+ (* Apply attributes *)
105105+ Array.iter (fun attr ->
106106+ let at = Jv.to_string (Jv.get attr "t") in
107107+ match at with
108108+ | "prop" ->
109109+ let k = Jv.to_string (Jv.get attr "k") in
110110+ let v = Jv.to_string (Jv.get attr "v") in
111111+ El.set_at (Jstr.v k) (Some (Jstr.v v)) el
112112+ | "style" ->
113113+ let k = Jv.to_string (Jv.get attr "k") in
114114+ let v = Jv.to_string (Jv.get attr "v") in
115115+ El.set_inline_style (Jstr.v k) (Jstr.v v) el
116116+ | "cls" ->
117117+ let v = Jv.to_string (Jv.get attr "v") in
118118+ El.set_class (Jstr.v v) true el
119119+ | "handler" ->
120120+ let ev_name = Jv.to_string (Jv.get attr "ev") in
121121+ let handler_id = Jv.to_string (Jv.get attr "id") in
122122+ let ev_type = Ev.Type.create (Jstr.v ev_name) in
123123+ let _listener = Ev.listen ev_type (fun _ev ->
124124+ let is_input =
125125+ let tn = Jstr.to_string (El.tag_name el) in
126126+ tn = "input" || tn = "select" || tn = "textarea"
127127+ in
128128+ let value =
129129+ if is_input then
130130+ Some (Jv.to_string (Jv.get (El.to_jv el) "value"))
131131+ else None
132132+ in
133133+ send ~widget_id ~handler_id ~event_type:ev_name ~value
134134+ ) (El.as_target el) in
135135+ ()
136136+ | _ -> ()
137137+ ) attrs_arr;
138138+ (* Append children *)
139139+ Array.iter (fun child ->
140140+ let child_el = render_node ~widget_id ~send child in
141141+ El.append_children el [ child_el ]
142142+ ) children_arr;
143143+ el
144144+ | _ ->
145145+ El.span []
146146+147147+(** Find or create a widget container. New containers are inserted right after
148148+ the currently active x-ocaml cell element, so widgets appear inline with
149149+ their code. On subsequent updates the existing container is reused in place. *)
150150+let find_or_create_container widget_id =
151151+ match Hashtbl.find_opt widgets widget_id with
152152+ | Some entry -> entry.container
153153+ | None ->
154154+ let container = El.div ~at:[ At.class' (Jstr.v "widget-container") ] [] in
155155+ El.set_at (Jstr.v "data-widget-id") (Some (Jstr.v widget_id)) container;
156156+ (* Insert after the active cell element, or fall back to document.body *)
157157+ (match !active_cell with
158158+ | Some cell_jv ->
159159+ (* Walk past any existing widget-containers that are already siblings
160160+ right after this cell, so multiple widgets from the same cell
161161+ stack in creation order. *)
162162+ let next_sibling = ref (Jv.get cell_jv "nextElementSibling") in
163163+ let insert_after = ref cell_jv in
164164+ while not (Jv.is_null !next_sibling || Jv.is_undefined !next_sibling) &&
165165+ (let cls = Jv.to_jstr (Jv.get !next_sibling "className") in
166166+ Jstr.equal cls (Jstr.v "widget-container")) do
167167+ insert_after := !next_sibling;
168168+ next_sibling := Jv.get !next_sibling "nextElementSibling"
169169+ done;
170170+ Jv.call !insert_after "insertAdjacentElement"
171171+ [| Jv.of_string "afterend"; El.to_jv container |] |> ignore
172172+ | None ->
173173+ (* No active cell — fall back to document.body *)
174174+ let body = El.to_jv (Document.body G.document) in
175175+ Jv.call body "appendChild" [| El.to_jv container |] |> ignore);
176176+ let entry = { container; widget_id; managed = None } in
177177+ Hashtbl.replace widgets widget_id entry;
178178+ container
179179+180180+(** Update (or create) a widget with a new view. *)
181181+let update_widget ~(send : send_fn) widget_id (view_json : Jv.t) =
182182+ let t = Jv.to_string (Jv.get view_json "t") in
183183+ if t = "managed" then begin
184184+ let kind = Jv.to_string (Jv.get view_json "kind") in
185185+ let config = Jv.to_string (Jv.get view_json "config") in
186186+ match Hashtbl.find_opt widgets widget_id with
187187+ | Some entry when entry.managed <> None ->
188188+ (* Already created — just update config *)
189189+ let (_k, state) = Option.get entry.managed in
190190+ (match Hashtbl.find_opt adapters kind with
191191+ | Some adapter -> adapter.update state config
192192+ | None -> ())
193193+ | _ ->
194194+ (* First render — create via adapter *)
195195+ let container = find_or_create_container widget_id in
196196+ (match Hashtbl.find_opt adapters kind with
197197+ | None ->
198198+ (* No adapter registered — render an error message *)
199199+ El.set_children container
200200+ [El.span [El.txt' (Printf.sprintf "No adapter for '%s'" kind)]]
201201+ | Some adapter ->
202202+ (* Wrap send so the adapter doesn't need to know its widget_id *)
203203+ let wrapped_send ~widget_id:_ ~handler_id ~event_type ~value =
204204+ send ~widget_id ~handler_id ~event_type ~value
205205+ in
206206+ let state = adapter.create (El.to_jv container) config wrapped_send in
207207+ let entry = { container; widget_id; managed = Some (kind, state) } in
208208+ Hashtbl.replace widgets widget_id entry)
209209+ end else begin
210210+ (* Existing Element/Text path — full DOM replacement *)
211211+ let container = find_or_create_container widget_id in
212212+ El.set_children container [];
213213+ let dom = render_node ~widget_id ~send view_json in
214214+ El.append_children container [ dom ]
215215+ end
216216+217217+(** Update config for a managed widget. *)
218218+let config_widget widget_id config =
219219+ match Hashtbl.find_opt widgets widget_id with
220220+ | Some { managed = Some (kind, state); _ } ->
221221+ (match Hashtbl.find_opt adapters kind with
222222+ | Some adapter -> adapter.update state config
223223+ | None -> ())
224224+ | _ -> ()
225225+226226+(** Send a command to a managed widget. *)
227227+let command_widget widget_id cmd data =
228228+ match Hashtbl.find_opt widgets widget_id with
229229+ | Some { managed = Some (kind, state); _ } ->
230230+ (match Hashtbl.find_opt adapters kind with
231231+ | Some adapter -> adapter.command state cmd data
232232+ | None -> ())
233233+ | _ -> ()
234234+235235+(** Remove a widget and its container. Calls adapter destroy for managed widgets. *)
236236+let clear_widget widget_id =
237237+ match Hashtbl.find_opt widgets widget_id with
238238+ | Some entry ->
239239+ (match entry.managed with
240240+ | Some (kind, state) ->
241241+ (match Hashtbl.find_opt adapters kind with
242242+ | Some adapter -> adapter.destroy state
243243+ | None -> ())
244244+ | None -> ());
245245+ El.remove entry.container;
246246+ Hashtbl.remove widgets widget_id
247247+ | None -> ()