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>
233 lines
9.4 KiB
OCaml
233 lines
9.4 KiB
OCaml
(** Pure flexbox layout engine.
|
|
|
|
Two-pass algorithm:
|
|
1. Measure (bottom-up): compute intrinsic sizes from text extents
|
|
and children accumulation.
|
|
2. Layout (top-down): allocate space starting from window bounds,
|
|
distributing via flex-grow and handling alignment/gap. *)
|
|
|
|
open Sx_native_types
|
|
|
|
(* -- Text measurement -- *)
|
|
|
|
let measure_text (cr : Cairo.context) (family : [`Sans | `Mono]) (weight : [`Normal | `Bold])
|
|
(slant : [`Normal | `Italic]) (size : float) (text : string) : float * float =
|
|
let font_name = match family with `Sans -> "sans-serif" | `Mono -> "monospace" in
|
|
let cairo_weight = match weight with `Normal -> Cairo.Normal | `Bold -> Cairo.Bold in
|
|
let cairo_slant = match slant with `Normal -> Cairo.Upright | `Italic -> Cairo.Italic in
|
|
Cairo.select_font_face cr ~slant:cairo_slant ~weight:cairo_weight font_name;
|
|
Cairo.set_font_size cr size;
|
|
let fe = Cairo.font_extents cr in
|
|
if String.length text = 0 then (0., fe.ascent +. fe.descent)
|
|
else begin
|
|
(* Word wrap not needed for POC -- measure as single line *)
|
|
let te = Cairo.text_extents cr text in
|
|
(te.Cairo.width +. te.Cairo.x_bearing, fe.ascent +. fe.descent)
|
|
end
|
|
|
|
(* -- Measure pass (bottom-up) -- *)
|
|
|
|
(** Set intrinsic [box.w] and [box.h] on each node based on text extents
|
|
and children accumulation. Does NOT set x/y. *)
|
|
let rec measure (cr : Cairo.context) (node : node) : unit =
|
|
(* Measure children first *)
|
|
List.iter (measure cr) node.children;
|
|
|
|
let pad = node.style.padding in
|
|
let pad_h = pad.left +. pad.right in
|
|
let pad_v = pad.top +. pad.bottom in
|
|
|
|
match node.text with
|
|
| Some txt ->
|
|
(* Leaf text node: measure the text *)
|
|
let (tw, th) = measure_text cr node.style.font_family node.style.font_weight
|
|
node.style.font_style node.style.font_size txt in
|
|
node.box.w <- tw +. pad_h;
|
|
node.box.h <- th +. pad_v
|
|
| None ->
|
|
if node.style.display = `None then begin
|
|
node.box.w <- 0.;
|
|
node.box.h <- 0.
|
|
end else begin
|
|
let visible_children = List.filter (fun c -> c.style.display <> `None) node.children in
|
|
let n_children = List.length visible_children in
|
|
let total_gap = if n_children > 1 then node.style.gap *. float_of_int (n_children - 1) else 0. in
|
|
match node.style.flex_direction with
|
|
| `Column ->
|
|
(* Stack vertically: width = max child width, height = sum of child heights + gaps *)
|
|
let max_w = List.fold_left (fun acc c ->
|
|
let cm = c.style.margin in
|
|
Float.max acc (c.box.w +. cm.left +. cm.right)
|
|
) 0. visible_children in
|
|
let sum_h = List.fold_left (fun acc c ->
|
|
let cm = c.style.margin in
|
|
acc +. c.box.h +. cm.top +. cm.bottom
|
|
) 0. visible_children in
|
|
node.box.w <- max_w +. pad_h;
|
|
node.box.h <- sum_h +. total_gap +. pad_v
|
|
| `Row ->
|
|
(* Stack horizontally: height = max child height, width = sum of child widths + gaps *)
|
|
let sum_w = List.fold_left (fun acc c ->
|
|
let cm = c.style.margin in
|
|
acc +. c.box.w +. cm.left +. cm.right
|
|
) 0. visible_children in
|
|
let max_h = List.fold_left (fun acc c ->
|
|
let cm = c.style.margin in
|
|
Float.max acc (c.box.h +. cm.top +. cm.bottom)
|
|
) 0. visible_children in
|
|
node.box.w <- sum_w +. total_gap +. pad_h;
|
|
node.box.h <- max_h +. pad_v
|
|
end;
|
|
|
|
(* Apply explicit width/height constraints *)
|
|
(match node.style.width with
|
|
| `Px w -> node.box.w <- w
|
|
| `Full | `Auto -> ());
|
|
(match node.style.height with
|
|
| `Px h -> node.box.h <- h
|
|
| `Full | `Auto -> ())
|
|
|
|
|
|
(* -- Layout pass (top-down) -- *)
|
|
|
|
(** Position all nodes within the given bounds [x, y, w, h].
|
|
Distributes space according to flex-grow and handles alignment. *)
|
|
let rec layout (node : node) (x : float) (y : float) (avail_w : float) (avail_h : float) : unit =
|
|
let margin = node.style.margin in
|
|
let x = x +. margin.left in
|
|
let y = y +. margin.top in
|
|
let avail_w = avail_w -. margin.left -. margin.right in
|
|
let avail_h = avail_h -. margin.top -. margin.bottom in
|
|
|
|
node.box.x <- x;
|
|
node.box.y <- y;
|
|
|
|
(* Determine actual width/height.
|
|
Container nodes with Auto width stretch to fill available space
|
|
(like CSS block-level elements), while text nodes keep intrinsic width. *)
|
|
let is_text_node = node.text <> None in
|
|
let w = match node.style.width with
|
|
| `Full -> avail_w
|
|
| `Px pw -> Float.min pw avail_w
|
|
| `Auto ->
|
|
if is_text_node then Float.min node.box.w avail_w
|
|
else avail_w (* containers expand to fill *)
|
|
in
|
|
let h = match node.style.height with
|
|
| `Full -> avail_h
|
|
| `Px ph -> Float.min ph avail_h
|
|
| `Auto -> node.box.h (* Use intrinsic height *)
|
|
in
|
|
|
|
node.box.w <- w;
|
|
node.box.h <- h;
|
|
|
|
if node.style.display = `None then ()
|
|
else begin
|
|
let pad = node.style.padding in
|
|
let inner_x = x +. pad.left in
|
|
let inner_y = y +. pad.top in
|
|
let inner_w = w -. pad.left -. pad.right in
|
|
let inner_h = h -. pad.top -. pad.bottom in
|
|
|
|
let visible_children = List.filter (fun c -> c.style.display <> `None) node.children in
|
|
|
|
match visible_children with
|
|
| [] -> () (* Leaf or empty container *)
|
|
| children ->
|
|
let n_children = List.length children in
|
|
let total_gap = if n_children > 1 then node.style.gap *. float_of_int (n_children - 1) else 0. in
|
|
|
|
match node.style.flex_direction with
|
|
| `Column ->
|
|
(* Calculate total intrinsic height and flex-grow sum *)
|
|
let total_intrinsic = List.fold_left (fun acc c ->
|
|
let cm = c.style.margin in
|
|
acc +. c.box.h +. cm.top +. cm.bottom
|
|
) 0. children in
|
|
let total_grow = List.fold_left (fun acc c -> acc +. c.style.flex_grow) 0. children in
|
|
let remaining = Float.max 0. (inner_h -. total_intrinsic -. total_gap) in
|
|
|
|
(* justify-content: space-between *)
|
|
let (start_offset, between_extra) = match node.style.justify_content with
|
|
| `Between when n_children > 1 ->
|
|
(0., remaining /. float_of_int (n_children - 1))
|
|
| `Center -> (remaining /. 2., 0.)
|
|
| `End -> (remaining, 0.)
|
|
| _ -> (0., 0.)
|
|
in
|
|
|
|
let cur_y = ref (inner_y +. start_offset) in
|
|
List.iter (fun child ->
|
|
let cm = child.style.margin in
|
|
let child_w = match child.style.width with
|
|
| `Full -> inner_w -. cm.left -. cm.right
|
|
| _ -> Float.min child.box.w (inner_w -. cm.left -. cm.right)
|
|
in
|
|
let extra_h = if total_grow > 0. then remaining *. child.style.flex_grow /. total_grow else 0. in
|
|
let child_h = child.box.h +. extra_h in
|
|
|
|
(* align-items: cross-axis alignment *)
|
|
let child_x = match node.style.align_items with
|
|
| `Center -> inner_x +. (inner_w -. child_w -. cm.left -. cm.right) /. 2.
|
|
| `End -> inner_x +. inner_w -. child_w -. cm.right
|
|
| `Stretch ->
|
|
(* Stretch: child takes full width *)
|
|
layout child (inner_x) !cur_y (inner_w) child_h;
|
|
cur_y := !cur_y +. child.box.h +. cm.top +. cm.bottom +. node.style.gap +. between_extra;
|
|
(* skip the normal layout below *)
|
|
inner_x (* dummy, won't be used *)
|
|
| _ -> inner_x
|
|
in
|
|
|
|
if node.style.align_items <> `Stretch then begin
|
|
layout child child_x !cur_y child_w child_h;
|
|
cur_y := !cur_y +. child.box.h +. cm.top +. cm.bottom +. node.style.gap +. between_extra
|
|
end
|
|
) children
|
|
|
|
| `Row ->
|
|
(* Calculate total intrinsic width and flex-grow sum *)
|
|
let total_intrinsic = List.fold_left (fun acc c ->
|
|
let cm = c.style.margin in
|
|
acc +. c.box.w +. cm.left +. cm.right
|
|
) 0. children in
|
|
let total_grow = List.fold_left (fun acc c -> acc +. c.style.flex_grow) 0. children in
|
|
let remaining = Float.max 0. (inner_w -. total_intrinsic -. total_gap) in
|
|
|
|
let (start_offset, between_extra) = match node.style.justify_content with
|
|
| `Between when n_children > 1 ->
|
|
(0., remaining /. float_of_int (n_children - 1))
|
|
| `Center -> (remaining /. 2., 0.)
|
|
| `End -> (remaining, 0.)
|
|
| _ -> (0., 0.)
|
|
in
|
|
|
|
let cur_x = ref (inner_x +. start_offset) in
|
|
List.iter (fun child ->
|
|
let cm = child.style.margin in
|
|
let extra_w = if total_grow > 0. then remaining *. child.style.flex_grow /. total_grow else 0. in
|
|
let child_w = child.box.w +. extra_w in
|
|
let child_h = match child.style.height with
|
|
| `Full -> inner_h -. cm.top -. cm.bottom
|
|
| _ -> Float.min child.box.h (inner_h -. cm.top -. cm.bottom)
|
|
in
|
|
|
|
(* align-items: cross-axis alignment *)
|
|
let child_y = match node.style.align_items with
|
|
| `Center -> inner_y +. (inner_h -. child_h -. cm.top -. cm.bottom) /. 2.
|
|
| `End -> inner_y +. inner_h -. child_h -. cm.bottom
|
|
| `Stretch ->
|
|
layout child !cur_x inner_y child_w inner_h;
|
|
cur_x := !cur_x +. child.box.w +. cm.left +. cm.right +. node.style.gap +. between_extra;
|
|
inner_y (* dummy *)
|
|
| _ -> inner_y
|
|
in
|
|
|
|
if node.style.align_items <> `Stretch then begin
|
|
layout child !cur_x child_y child_w child_h;
|
|
cur_x := !cur_x +. child.box.w +. cm.left +. cm.right +. node.style.gap +. between_extra
|
|
end
|
|
) children
|
|
end
|