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>
278 lines
12 KiB
OCaml
278 lines
12 KiB
OCaml
(** Parse Tailwind CSS class strings into native style records.
|
|
|
|
Supports ~50 common utility classes covering layout, spacing,
|
|
sizing, typography, colors, borders, and effects. *)
|
|
|
|
open Sx_native_types
|
|
|
|
(* -- Color palette (Tailwind stone + violet) -- *)
|
|
|
|
let white = { r = 1.0; g = 1.0; b = 1.0; a = 1.0 }
|
|
let black = { r = 0.0; g = 0.0; b = 0.0; a = 1.0 }
|
|
|
|
let stone_50 = { r = 0.980; g = 0.976; b = 0.973; a = 1.0 }
|
|
let stone_100 = { r = 0.961; g = 0.953; b = 0.945; a = 1.0 }
|
|
let stone_200 = { r = 0.906; g = 0.890; b = 0.875; a = 1.0 }
|
|
let stone_300 = { r = 0.839; g = 0.812; b = 0.788; a = 1.0 }
|
|
let stone_400 = { r = 0.659; g = 0.616; b = 0.576; a = 1.0 }
|
|
let stone_500 = { r = 0.471; g = 0.431; b = 0.396; a = 1.0 }
|
|
let stone_600 = { r = 0.341; g = 0.306; b = 0.275; a = 1.0 }
|
|
let stone_700 = { r = 0.267; g = 0.231; b = 0.208; a = 1.0 }
|
|
(* stone_800 is already in sx_native_types *)
|
|
let stone_900 = { r = 0.106; g = 0.098; b = 0.090; a = 1.0 }
|
|
|
|
let violet_50 = { r = 0.961; g = 0.953; b = 1.0; a = 1.0 }
|
|
let violet_100 = { r = 0.929; g = 0.906; b = 0.996; a = 1.0 }
|
|
let violet_200 = { r = 0.867; g = 0.820; b = 0.992; a = 1.0 }
|
|
let violet_300 = { r = 0.769; g = 0.686; b = 0.984; a = 1.0 }
|
|
let violet_400 = { r = 0.655; g = 0.525; b = 0.969; a = 1.0 }
|
|
let violet_500 = { r = 0.545; g = 0.361; b = 0.945; a = 1.0 }
|
|
let violet_600 = { r = 0.486; g = 0.227; b = 0.929; a = 1.0 }
|
|
let violet_700 = { r = 0.427; g = 0.176; b = 0.831; a = 1.0 }
|
|
let violet_800 = { r = 0.357; g = 0.153; b = 0.694; a = 1.0 }
|
|
let violet_900 = { r = 0.298; g = 0.133; b = 0.576; a = 1.0 }
|
|
|
|
let red_500 = { r = 0.937; g = 0.267; b = 0.267; a = 1.0 }
|
|
let red_600 = { r = 0.863; g = 0.145; b = 0.145; a = 1.0 }
|
|
let blue_500 = { r = 0.231; g = 0.510; b = 0.965; a = 1.0 }
|
|
let blue_600 = { r = 0.145; g = 0.388; b = 0.922; a = 1.0 }
|
|
let green_500 = { r = 0.133; g = 0.773; b = 0.369; a = 1.0 }
|
|
let green_600 = { r = 0.086; g = 0.635; b = 0.290; a = 1.0 }
|
|
let amber_500 = { r = 0.961; g = 0.718; b = 0.078; a = 1.0 }
|
|
|
|
(* -- Spacing scale (Tailwind: 1 unit = 4px) -- *)
|
|
|
|
let spacing n = float_of_int n *. 4.0
|
|
|
|
(* -- Font sizes (Tailwind) -- *)
|
|
|
|
let font_size_of = function
|
|
| "text-xs" -> 12.
|
|
| "text-sm" -> 14.
|
|
| "text-base" -> 16.
|
|
| "text-lg" -> 18.
|
|
| "text-xl" -> 20.
|
|
| "text-2xl" -> 24.
|
|
| "text-3xl" -> 30.
|
|
| "text-4xl" -> 36.
|
|
| "text-5xl" -> 48.
|
|
| _ -> 16.
|
|
|
|
(* -- Parse a single Tailwind class, mutating a style -- *)
|
|
|
|
let parse_spacing_value s =
|
|
(* Extract numeric value from strings like "p-4", "gap-2" *)
|
|
match int_of_string_opt s with
|
|
| Some n -> spacing n
|
|
| None -> 0.
|
|
|
|
let bg_color_of cls =
|
|
match cls with
|
|
| "bg-white" -> Some white
|
|
| "bg-black" -> Some black
|
|
| "bg-stone-50" -> Some stone_50
|
|
| "bg-stone-100" -> Some stone_100
|
|
| "bg-stone-200" -> Some stone_200
|
|
| "bg-stone-300" -> Some stone_300
|
|
| "bg-stone-400" -> Some stone_400
|
|
| "bg-stone-500" -> Some stone_500
|
|
| "bg-stone-600" -> Some stone_600
|
|
| "bg-stone-700" -> Some stone_700
|
|
| "bg-stone-800" -> Some stone_800
|
|
| "bg-stone-900" -> Some stone_900
|
|
| "bg-violet-50" -> Some violet_50
|
|
| "bg-violet-100" -> Some violet_100
|
|
| "bg-violet-200" -> Some violet_200
|
|
| "bg-violet-300" -> Some violet_300
|
|
| "bg-violet-400" -> Some violet_400
|
|
| "bg-violet-500" -> Some violet_500
|
|
| "bg-violet-600" -> Some violet_600
|
|
| "bg-violet-700" -> Some violet_700
|
|
| "bg-violet-800" -> Some violet_800
|
|
| "bg-violet-900" -> Some violet_900
|
|
| "bg-red-500" -> Some red_500
|
|
| "bg-red-600" -> Some red_600
|
|
| "bg-blue-500" -> Some blue_500
|
|
| "bg-blue-600" -> Some blue_600
|
|
| "bg-green-500" -> Some green_500
|
|
| "bg-green-600" -> Some green_600
|
|
| "bg-amber-500" -> Some amber_500
|
|
| _ -> None
|
|
|
|
let text_color_of cls =
|
|
match cls with
|
|
| "text-white" -> Some white
|
|
| "text-black" -> Some black
|
|
| "text-stone-50" -> Some stone_50
|
|
| "text-stone-100" -> Some stone_100
|
|
| "text-stone-200" -> Some stone_200
|
|
| "text-stone-300" -> Some stone_300
|
|
| "text-stone-400" -> Some stone_400
|
|
| "text-stone-500" -> Some stone_500
|
|
| "text-stone-600" -> Some stone_600
|
|
| "text-stone-700" -> Some stone_700
|
|
| "text-stone-800" -> Some stone_800
|
|
| "text-stone-900" -> Some stone_900
|
|
| "text-violet-50" -> Some violet_50
|
|
| "text-violet-100" -> Some violet_100
|
|
| "text-violet-200" -> Some violet_200
|
|
| "text-violet-300" -> Some violet_300
|
|
| "text-violet-400" -> Some violet_400
|
|
| "text-violet-500" -> Some violet_500
|
|
| "text-violet-600" -> Some violet_600
|
|
| "text-violet-700" -> Some violet_700
|
|
| "text-violet-800" -> Some violet_800
|
|
| "text-violet-900" -> Some violet_900
|
|
| "text-red-500" -> Some red_500
|
|
| "text-red-600" -> Some red_600
|
|
| "text-blue-500" -> Some blue_500
|
|
| "text-blue-600" -> Some blue_600
|
|
| "text-green-500" -> Some green_500
|
|
| "text-green-600" -> Some green_600
|
|
| "text-amber-500" -> Some amber_500
|
|
| _ -> None
|
|
|
|
let border_color_of cls =
|
|
match cls with
|
|
| "border-stone-100" -> Some stone_100
|
|
| "border-stone-200" -> Some stone_200
|
|
| "border-stone-300" -> Some stone_300
|
|
| "border-violet-200" -> Some violet_200
|
|
| "border-violet-300" -> Some violet_300
|
|
| "border-white" -> Some white
|
|
| _ -> None
|
|
|
|
(** Apply a single Tailwind class to a style, returning the updated style. *)
|
|
let apply_class (s : style) (cls : string) : style =
|
|
(* Layout *)
|
|
if cls = "flex" then { s with display = `Flex; flex_direction = `Row }
|
|
else if cls = "flex-col" then { s with display = `Flex; flex_direction = `Column }
|
|
else if cls = "flex-row" then { s with display = `Flex; flex_direction = `Row }
|
|
else if cls = "block" then { s with display = `Block }
|
|
else if cls = "hidden" then { s with display = `None }
|
|
else if cls = "items-center" then { s with align_items = `Center }
|
|
else if cls = "items-start" then { s with align_items = `Start }
|
|
else if cls = "items-end" then { s with align_items = `End }
|
|
else if cls = "items-stretch" then { s with align_items = `Stretch }
|
|
else if cls = "justify-center" then { s with justify_content = `Center }
|
|
else if cls = "justify-between" then { s with justify_content = `Between }
|
|
else if cls = "justify-start" then { s with justify_content = `Start }
|
|
else if cls = "justify-end" then { s with justify_content = `End }
|
|
else if cls = "flex-grow" || cls = "grow" then { s with flex_grow = 1. }
|
|
(* Gap *)
|
|
else if String.length cls > 4 && String.sub cls 0 4 = "gap-" then
|
|
let n = String.sub cls 4 (String.length cls - 4) in
|
|
{ s with gap = parse_spacing_value n }
|
|
(* Padding *)
|
|
else if String.length cls > 2 && String.sub cls 0 2 = "p-" && cls.[2] <> 'x' && cls.[2] <> 'y' && cls.[2] <> 'l' && cls.[2] <> 'r' && cls.[2] <> 't' && cls.[2] <> 'b' then
|
|
let n = String.sub cls 2 (String.length cls - 2) in
|
|
let v = parse_spacing_value n in
|
|
{ s with padding = { top = v; right = v; bottom = v; left = v } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "px-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
let v = parse_spacing_value n in
|
|
{ s with padding = { s.padding with left = v; right = v } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "py-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
let v = parse_spacing_value n in
|
|
{ s with padding = { s.padding with top = v; bottom = v } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "pt-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
{ s with padding = { s.padding with top = parse_spacing_value n } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "pb-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
{ s with padding = { s.padding with bottom = parse_spacing_value n } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "pl-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
{ s with padding = { s.padding with left = parse_spacing_value n } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "pr-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
{ s with padding = { s.padding with right = parse_spacing_value n } }
|
|
(* Margin *)
|
|
else if String.length cls > 2 && String.sub cls 0 2 = "m-" && cls.[2] <> 'x' && cls.[2] <> 'y' && cls.[2] <> 'l' && cls.[2] <> 'r' && cls.[2] <> 't' && cls.[2] <> 'b' then
|
|
let n = String.sub cls 2 (String.length cls - 2) in
|
|
let v = parse_spacing_value n in
|
|
{ s with margin = { top = v; right = v; bottom = v; left = v } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "mx-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
let v = parse_spacing_value n in
|
|
{ s with margin = { s.margin with left = v; right = v } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "my-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
let v = parse_spacing_value n in
|
|
{ s with margin = { s.margin with top = v; bottom = v } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "mt-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
{ s with margin = { s.margin with top = parse_spacing_value n } }
|
|
else if String.length cls > 3 && String.sub cls 0 3 = "mb-" then
|
|
let n = String.sub cls 3 (String.length cls - 3) in
|
|
{ s with margin = { s.margin with bottom = parse_spacing_value n } }
|
|
(* Sizing *)
|
|
else if cls = "w-full" then { s with width = `Full }
|
|
else if cls = "h-full" then { s with height = `Full }
|
|
else if String.length cls > 2 && String.sub cls 0 2 = "w-" then
|
|
let n = String.sub cls 2 (String.length cls - 2) in
|
|
(match int_of_string_opt n with
|
|
| Some v -> { s with width = `Px (float_of_int v *. 4.) }
|
|
| None -> s)
|
|
else if String.length cls > 2 && String.sub cls 0 2 = "h-" then
|
|
let n = String.sub cls 2 (String.length cls - 2) in
|
|
(match int_of_string_opt n with
|
|
| Some v -> { s with height = `Px (float_of_int v *. 4.) }
|
|
| None -> s)
|
|
(* Typography *)
|
|
else if cls = "font-bold" then { s with font_weight = `Bold }
|
|
else if cls = "font-semibold" then { s with font_weight = `Bold }
|
|
else if cls = "font-normal" then { s with font_weight = `Normal }
|
|
else if cls = "italic" then { s with font_style = `Italic }
|
|
else if cls = "font-mono" then { s with font_family = `Mono }
|
|
else if String.length cls >= 5 && String.sub cls 0 5 = "text-" then
|
|
(* Could be text color or text size *)
|
|
let rest = String.sub cls 5 (String.length cls - 5) in
|
|
if rest = "xs" || rest = "sm" || rest = "base" || rest = "lg"
|
|
|| rest = "xl" || rest = "2xl" || rest = "3xl" || rest = "4xl"
|
|
|| rest = "5xl" then
|
|
{ s with font_size = font_size_of cls }
|
|
else
|
|
(match text_color_of cls with
|
|
| Some c -> { s with text_color = c }
|
|
| None -> s)
|
|
(* Background *)
|
|
else if String.length cls >= 3 && String.sub cls 0 3 = "bg-" then
|
|
(match bg_color_of cls with
|
|
| Some c -> { s with bg_color = Some c }
|
|
| None -> s)
|
|
(* Borders *)
|
|
else if cls = "rounded" then { s with border_radius = 4. }
|
|
else if cls = "rounded-md" then { s with border_radius = 6. }
|
|
else if cls = "rounded-lg" then { s with border_radius = 8. }
|
|
else if cls = "rounded-xl" then { s with border_radius = 12. }
|
|
else if cls = "rounded-2xl" then { s with border_radius = 16. }
|
|
else if cls = "rounded-full" then { s with border_radius = 9999. }
|
|
else if cls = "border" then
|
|
{ s with border_width = 1.;
|
|
border_color = (if s.border_color = None then Some stone_200 else s.border_color) }
|
|
else if cls = "border-2" then
|
|
{ s with border_width = 2.;
|
|
border_color = (if s.border_color = None then Some stone_200 else s.border_color) }
|
|
else if String.length cls >= 7 && String.sub cls 0 7 = "border-" then
|
|
(match border_color_of cls with
|
|
| Some c -> { s with border_color = Some c;
|
|
border_width = (if s.border_width = 0. then 1. else s.border_width) }
|
|
| None -> s)
|
|
(* Shadow *)
|
|
else if cls = "shadow" then { s with shadow = `Sm }
|
|
else if cls = "shadow-md" then { s with shadow = `Md }
|
|
else if cls = "shadow-lg" then { s with shadow = `Md }
|
|
(* Overflow *)
|
|
else if cls = "overflow-hidden" then { s with overflow_hidden = true }
|
|
else s (* unknown class: ignore *)
|
|
|
|
(** Parse a space-separated Tailwind class string into a [style]. *)
|
|
let parse_classes ?(base = default_style) (classes : string) : style =
|
|
let parts = String.split_on_char ' ' classes in
|
|
List.fold_left (fun s cls ->
|
|
let cls = String.trim cls in
|
|
if cls = "" then s else apply_class s cls
|
|
) base parts
|