Files
rose-ash/hosts/native/lib/sx_native_style.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

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