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>
This commit is contained in:
232
hosts/native/lib/sx_native_layout.ml
Normal file
232
hosts/native/lib/sx_native_layout.ml
Normal file
@@ -0,0 +1,232 @@
|
||||
(** 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
|
||||
Reference in New Issue
Block a user