(** Widget renderer for x-ocaml. Renders view node JSON (from js_top_worker widget protocol) into real DOM elements using Brr, and wires event handlers back to the worker. Supports two kinds of widgets: - Element/Text views: declarative DOM trees, fully replaced on each update - Managed widgets: delegate to registered adapters (e.g. Leaflet maps) that manage their own DOM and respond to config updates and commands *) open Brr (** Type alias for the function that sends widget events back to the worker. *) type send_fn = widget_id:string -> handler_id:string -> event_type:string -> value:string option -> unit (** A managed widget adapter. Registered client-side per [kind]. All functions receive and return raw Jv.t values (JS objects). *) type adapter = { create : Jv.t -> string -> send_fn -> Jv.t; (** [create container config send] creates the widget and returns adapter state *) update : Jv.t -> string -> unit; (** [update state config] reconciles a config change *) command : Jv.t -> string -> string -> unit; (** [command state cmd data] handles an imperative command *) destroy : Jv.t -> unit; (** [destroy state] cleans up *) } (** Global adapter registry: kind -> adapter *) let adapters : (string, adapter) Hashtbl.t = Hashtbl.create 8 (** Register an adapter for the given [kind] string. *) let register_adapter kind adapter = Hashtbl.replace adapters kind adapter (** Register an adapter from JavaScript code. The JS must be an IIFE returning [{create, update, command, destroy}]. [send] in JS is [send(handler_id, value_string)]. *) let register_js_adapter ~(send : send_fn) kind js_code = let obj = Jv.call Jv.global "eval" [| Jv.of_string js_code |] in let adapter = { create = (fun container_jv config_str send_fn -> let js_send = Jv.repr (fun handler_id value -> let hid = Jv.to_string handler_id in let v = if Jv.is_null value || Jv.is_undefined value then None else Some (Jv.to_string value) in send_fn ~widget_id:"" ~handler_id:hid ~event_type:hid ~value:v ) in Jv.call obj "create" [| container_jv; Jv.of_string config_str; js_send |]); update = (fun state config_str -> Jv.call obj "update" [| state; Jv.of_string config_str |] |> ignore); command = (fun state cmd data -> Jv.call obj "command" [| state; Jv.of_string cmd; Jv.of_string data |] |> ignore); destroy = (fun state -> Jv.call obj "destroy" [| state |] |> ignore); } in ignore send; (* send is captured by the adapter's create wrapper at call time *) Hashtbl.replace adapters kind adapter (** Per-widget state *) type widget_entry = { container : El.t; widget_id : string; managed : (string * Jv.t) option; (** For managed widgets: (kind, adapter_state) *) } (** Global registry of active widgets *) let widgets : (string, widget_entry) Hashtbl.t = Hashtbl.create 16 (** The current anchor element — new widget containers are inserted after this. Set by [set_active_cell] before each cell eval begins. *) let active_cell : Jv.t option ref = ref None (** Set the currently active cell element. Call this before each eval so that any widgets created during that eval are placed right after the cell. *) let set_active_cell (el : Jv.t) = active_cell := Some el (** Recursively render a view node JSON object to a DOM element. [send] is called when an event handler fires. *) let rec render_node ~widget_id ~(send : send_fn) (node : Jv.t) : El.t = let t = Jv.to_string (Jv.get node "t") in match t with | "txt" -> let v = Jv.to_string (Jv.get node "v") in El.span [ El.txt' v ] | "el" -> let tag = Jv.to_string (Jv.get node "tag") in let attrs_arr = let a = Jv.get node "a" in if Jv.is_none a || Jv.is_undefined a then [||] else Jv.to_jv_array a in let children_arr = let c = Jv.get node "c" in if Jv.is_none c || Jv.is_undefined c then [||] else Jv.to_jv_array c in let el = El.v (Jstr.v tag) [] in (* Apply attributes *) Array.iter (fun attr -> let at = Jv.to_string (Jv.get attr "t") in match at with | "prop" -> let k = Jv.to_string (Jv.get attr "k") in let v = Jv.to_string (Jv.get attr "v") in El.set_at (Jstr.v k) (Some (Jstr.v v)) el | "style" -> let k = Jv.to_string (Jv.get attr "k") in let v = Jv.to_string (Jv.get attr "v") in El.set_inline_style (Jstr.v k) (Jstr.v v) el | "cls" -> let v = Jv.to_string (Jv.get attr "v") in El.set_class (Jstr.v v) true el | "handler" -> let ev_name = Jv.to_string (Jv.get attr "ev") in let handler_id = Jv.to_string (Jv.get attr "id") in let ev_type = Ev.Type.create (Jstr.v ev_name) in let _listener = Ev.listen ev_type (fun _ev -> let is_input = let tn = String.lowercase_ascii (Jstr.to_string (El.tag_name el)) in tn = "input" || tn = "select" || tn = "textarea" in let value = if is_input then Some (Jv.to_string (Jv.get (El.to_jv el) "value")) else None in send ~widget_id ~handler_id ~event_type:ev_name ~value ) (El.as_target el) in () | _ -> () ) attrs_arr; (* Append children *) Array.iter (fun child -> let child_el = render_node ~widget_id ~send child in El.append_children el [ child_el ] ) children_arr; el | _ -> El.span [] (** Find or create a widget container. New containers are inserted right after the currently active x-ocaml cell element, so widgets appear inline with their code. On subsequent updates the existing container is reused in place. *) let find_or_create_container widget_id = match Hashtbl.find_opt widgets widget_id with | Some entry -> entry.container | None -> let container = El.div ~at:[ At.class' (Jstr.v "widget-container") ] [] in El.set_at (Jstr.v "data-widget-id") (Some (Jstr.v widget_id)) container; (* Insert after the active cell element, or fall back to document.body *) (match !active_cell with | Some cell_jv -> (* Walk past any existing widget-containers that are already siblings right after this cell, so multiple widgets from the same cell stack in creation order. *) let next_sibling = ref (Jv.get cell_jv "nextElementSibling") in let insert_after = ref cell_jv in while not (Jv.is_null !next_sibling || Jv.is_undefined !next_sibling) && (let cls = Jv.to_jstr (Jv.get !next_sibling "className") in Jstr.equal cls (Jstr.v "widget-container")) do insert_after := !next_sibling; next_sibling := Jv.get !next_sibling "nextElementSibling" done; Jv.call !insert_after "insertAdjacentElement" [| Jv.of_string "afterend"; El.to_jv container |] |> ignore | None -> (* No active cell — fall back to document.body *) let body = El.to_jv (Document.body G.document) in Jv.call body "appendChild" [| El.to_jv container |] |> ignore); let entry = { container; widget_id; managed = None } in Hashtbl.replace widgets widget_id entry; container (** Update (or create) a widget with a new view. *) let update_widget ~(send : send_fn) widget_id (view_json : Jv.t) = let t = Jv.to_string (Jv.get view_json "t") in if t = "managed" then begin let kind = Jv.to_string (Jv.get view_json "kind") in let config = Jv.to_string (Jv.get view_json "config") in match Hashtbl.find_opt widgets widget_id with | Some entry when entry.managed <> None -> (* Already created — just update config *) let (_k, state) = Option.get entry.managed in (match Hashtbl.find_opt adapters kind with | Some adapter -> adapter.update state config | None -> ()) | _ -> (* First render — create via adapter *) let container = find_or_create_container widget_id in (match Hashtbl.find_opt adapters kind with | None -> (* No adapter registered — render an error message *) El.set_children container [El.span [El.txt' (Printf.sprintf "No adapter for '%s'" kind)]] | Some adapter -> (* Wrap send so the adapter doesn't need to know its widget_id *) let wrapped_send ~widget_id:_ ~handler_id ~event_type ~value = send ~widget_id ~handler_id ~event_type ~value in let state = adapter.create (El.to_jv container) config wrapped_send in let entry = { container; widget_id; managed = Some (kind, state) } in Hashtbl.replace widgets widget_id entry) end else begin (* Existing Element/Text path — full DOM replacement *) let container = find_or_create_container widget_id in El.set_children container []; let dom = render_node ~widget_id ~send view_json in El.append_children container [ dom ] end (** Update config for a managed widget. *) let config_widget widget_id config = match Hashtbl.find_opt widgets widget_id with | Some { managed = Some (kind, state); _ } -> (match Hashtbl.find_opt adapters kind with | Some adapter -> adapter.update state config | None -> ()) | _ -> () (** Send a command to a managed widget. *) let command_widget widget_id cmd data = match Hashtbl.find_opt widgets widget_id with | Some { managed = Some (kind, state); _ } -> (match Hashtbl.find_opt adapters kind with | Some adapter -> adapter.command state cmd data | None -> ()) | _ -> () (** Remove a widget and its container. Calls adapter destroy for managed widgets. *) let clear_widget widget_id = match Hashtbl.find_opt widgets widget_id with | Some entry -> (match entry.managed with | Some (kind, state) -> (match Hashtbl.find_opt adapters kind with | Some adapter -> adapter.destroy state | None -> ()) | None -> ()); El.remove entry.container; Hashtbl.remove widgets widget_id | None -> ()