Files
rose-ash/hosts/native/lib/sx_native_render.ml
giles f0d8db9b68 Add native SX desktop browser — renders s-expressions to pixels
A 5.9MB OCaml binary that renders SX pages directly using SDL2 + Cairo,
bypassing HTML/CSS/JS entirely. Can fetch live pages from sx.rose-ash.com
or render local .sx files.

Architecture (1,387 lines of new code):
  sx_native_types.ml   — render nodes, styles, layout boxes, color palette
  sx_native_style.ml   — ~40 Tailwind classes → native style records
  sx_native_layout.ml  — pure OCaml flexbox (measure + position)
  sx_native_render.ml  — SX value tree → native render nodes
  sx_native_paint.ml   — render nodes → Cairo draw commands
  sx_native_fetch.ml   — HTTP fetch via curl with SX-Request headers
  sx_native_app.ml     — SDL2 window, event loop, navigation, scrolling

Usage:
  dune build                           # from hosts/native/
  ./sx_native_app.exe /sx/             # browse sx.rose-ash.com home
  ./sx_native_app.exe /sx/(applications.(native-browser))
  ./sx_native_app.exe demo/counter.sx  # render local file

Features:
  - Flexbox layout (row/column, gap, padding, alignment, grow)
  - Tailwind color palette (stone, violet, white)
  - Rounded corners, borders, shadows
  - Text rendering with font size/weight
  - Click navigation (links trigger refetch)
  - Scroll with mouse wheel
  - Window resize → re-layout
  - URL bar showing current path

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 17:01:22 +00:00

222 lines
8.6 KiB
OCaml

(** Convert an [Sx_types.value] tree into a native [node] render tree.
Walks the parsed SX AST and produces nodes for HTML-like tags
(div, p, h1-h6, span, etc.), extracting :class attributes for
styling and string content for text nodes. Unknown forms are
rendered as gray placeholders. *)
open Sx_native_types
open Sx_native_style
(* -- Tag default styles -- *)
let tag_base_style (tag : string) : style =
match tag with
| "h1" -> { default_style with font_size = 36.; font_weight = `Bold }
| "h2" -> { default_style with font_size = 30.; font_weight = `Bold }
| "h3" -> { default_style with font_size = 24.; font_weight = `Bold }
| "h4" -> { default_style with font_size = 20.; font_weight = `Bold }
| "h5" -> { default_style with font_size = 18.; font_weight = `Bold }
| "h6" -> { default_style with font_size = 16.; font_weight = `Bold }
| "p" -> { default_style with flex_direction = `Row }
| "span" -> { default_style with flex_direction = `Row }
| "div" -> default_style
| "section" -> default_style
| "article" -> default_style
| "main" -> default_style
| "header" -> default_style
| "footer" -> default_style
| "nav" -> { default_style with flex_direction = `Row }
| "button" ->
{ default_style with
flex_direction = `Row;
padding = { top = 8.; right = 16.; bottom = 8.; left = 16. };
bg_color = Some violet_600;
text_color = white;
border_radius = 6.;
align_items = `Center;
justify_content = `Center }
| "a" -> { default_style with flex_direction = `Row; text_color = violet_600 }
| "code" ->
{ default_style with
font_family = `Mono;
font_size = 14.;
bg_color = Some stone_100;
padding = { top = 2.; right = 4.; bottom = 2.; left = 4. };
border_radius = 4. }
| "pre" ->
{ default_style with
font_family = `Mono;
font_size = 14.;
bg_color = Some stone_100;
padding = { top = 12.; right = 16.; bottom = 12.; left = 16. };
border_radius = 8. }
| "strong" | "b" -> { default_style with font_weight = `Bold; flex_direction = `Row }
| "em" | "i" -> { default_style with font_style = `Italic; flex_direction = `Row }
| "ul" -> { default_style with padding = { zero_edges with left = 16. } }
| "ol" -> { default_style with padding = { zero_edges with left = 16. } }
| "li" -> { default_style with flex_direction = `Row; gap = 4. }
| "table" -> default_style
| "thead" | "tbody" -> default_style
| "tr" -> { default_style with flex_direction = `Row; gap = 0. }
| "th" -> { default_style with font_weight = `Bold; padding = { top = 4.; right = 8.; bottom = 4.; left = 8. } }
| "td" -> { default_style with padding = { top = 4.; right = 8.; bottom = 4.; left = 8. } }
| "hr" ->
{ default_style with
height = `Px 1.;
bg_color = Some stone_200;
width = `Full }
| "br" -> { default_style with height = `Px 16. }
| "img" ->
{ default_style with
width = `Px 200.;
height = `Px 150.;
bg_color = Some stone_200;
border_radius = 4. }
| _ -> default_style
(* -- Known HTML tags -- *)
let is_html_tag = function
| "div" | "span" | "p" | "section" | "article" | "main" | "header"
| "footer" | "nav" | "aside"
| "h1" | "h2" | "h3" | "h4" | "h5" | "h6"
| "button" | "a" | "input" | "form" | "label" | "select" | "textarea"
| "ul" | "ol" | "li"
| "table" | "thead" | "tbody" | "tr" | "th" | "td"
| "strong" | "b" | "em" | "i" | "u" | "s"
| "code" | "pre" | "blockquote"
| "img" | "video" | "audio" | "source"
| "hr" | "br"
| "head" | "body" | "html" | "title" | "meta" | "link" | "script" | "style"
| "small" | "mark" | "sup" | "sub" | "abbr" | "time"
| "figure" | "figcaption" | "details" | "summary"
| "dl" | "dt" | "dd" -> true
| _ -> false
(* Void/skip tags -- don't render these *)
let is_skip_tag = function
| "head" | "meta" | "link" | "script" | "style" | "title"
| "source" | "input" -> true
| _ -> false
(* -- Extract keyword args from SX list -- *)
(** Extract keyword arguments and children from an SX element's argument list.
Returns [(attrs, children)] where attrs is a (key, value) list. *)
let extract_attrs (items : Sx_types.value list) : (string * Sx_types.value) list * Sx_types.value list =
let rec go attrs children = function
| [] -> (List.rev attrs, List.rev children)
| Sx_types.Keyword k :: v :: rest ->
go ((k, v) :: attrs) children rest
| other :: rest ->
go attrs (other :: children) rest
in
go [] [] items
(** Get a string attribute from keyword args. *)
let get_string_attr (attrs : (string * Sx_types.value) list) (key : string) : string option =
match List.assoc_opt key attrs with
| Some (Sx_types.String s) -> Some s
| _ -> None
(* -- Render SX values to native nodes -- *)
(** Make a text leaf node with inherited style. *)
let make_text_node (style : style) (text : string) : node =
{ tag = "#text"; style; children = []; text = Some text;
box = make_box (); href = None; on_click = None }
(** Render an SX value tree to a native node tree.
[~navigate] callback is invoked when a link is clicked. *)
let rec render ?(navigate : (string -> unit) option) (value : Sx_types.value) : node option =
match value with
| Sx_types.String s ->
Some (make_text_node default_style s)
| Sx_types.Number n ->
let s = if Float.is_integer n then string_of_int (int_of_float n)
else Printf.sprintf "%g" n in
Some (make_text_node default_style s)
| Sx_types.Bool true -> Some (make_text_node default_style "true")
| Sx_types.Bool false -> Some (make_text_node default_style "false")
| Sx_types.Nil -> None
| Sx_types.Keyword _ -> None (* bare keywords are attr markers *)
| Sx_types.Symbol _ -> None (* bare symbols are not renderable *)
| Sx_types.List (Sx_types.Symbol tag :: rest) when is_html_tag tag ->
if is_skip_tag tag then None
else begin
let (attrs, children_sx) = extract_attrs rest in
let class_str = get_string_attr attrs "class" in
let href = get_string_attr attrs "href" in
(* Build style: tag defaults + class overrides *)
let base = tag_base_style tag in
let style = match class_str with
| Some cls -> parse_classes ~base cls
| None -> base
in
(* Special: li gets a bullet prefix *)
let extra_children = if tag = "li" then
[make_text_node { style with flex_direction = `Row } "\xe2\x80\xa2 "]
else [] in
(* Render children *)
let children = extra_children @ List.filter_map (render ?navigate) children_sx in
(* For link nodes, set up navigation *)
let on_click = match href, navigate with
| Some h, Some nav -> Some (fun () -> nav h)
| _ -> None
in
Some { tag; style; children; text = None;
box = make_box (); href; on_click }
end
(* Component calls (~name ...) -- render as placeholder *)
| Sx_types.List (Sx_types.Symbol name :: rest) when String.length name > 0 && name.[0] = '~' ->
let (attrs, children_sx) = extract_attrs rest in
let class_str = get_string_attr attrs "class" in
let base = { default_style with
border_width = 1.;
border_color = Some violet_200;
border_radius = 4.;
padding = { top = 8.; right = 8.; bottom = 8.; left = 8. } } in
let style = match class_str with
| Some cls -> parse_classes ~base cls
| None -> base
in
let label = make_text_node
{ default_style with font_size = 12.; text_color = violet_500; font_family = `Mono }
name in
let children = label :: List.filter_map (render ?navigate) children_sx in
Some { tag = "component"; style; children; text = None;
box = make_box (); href = None; on_click = None }
(* Unknown list forms -- try to render children *)
| Sx_types.List items ->
let children = List.filter_map (render ?navigate) items in
if children = [] then None
else if List.length children = 1 then Some (List.hd children)
else
Some { tag = "group"; style = default_style; children; text = None;
box = make_box (); href = None; on_click = None }
| _ -> None (* Lambda, Dict, etc. -- skip *)
(** Render a list of top-level SX values into a single root node. *)
let render_page ?(navigate : (string -> unit) option) (values : Sx_types.value list) : node =
let children = List.filter_map (render ?navigate) values in
(* Wrap everything in a root container *)
{ tag = "root";
style = { default_style with
width = `Full;
padding = { top = 0.; right = 0.; bottom = 0.; left = 0. } };
children;
text = None;
box = make_box ();
href = None;
on_click = None }