Files
rose-ash/hosts/native/bin/sx_native_app.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

277 lines
9.3 KiB
OCaml

(** SX Native Browser -- renders s-expressions directly to pixels.
A proof-of-concept desktop browser that parses .sx files and
renders them using SDL2 + Cairo, with no HTML/CSS/JS engine. *)
open Tsdl
open Sx_native
open Sx_native_types
(* -- Helpers for SDL result handling -- *)
let sdl_ok = function
| Ok v -> v
| Error (`Msg e) -> failwith ("SDL error: " ^ e)
(* -- State -- *)
type app_state = {
mutable current_url : string;
mutable root : node;
mutable needs_repaint : bool;
mutable win_w : int;
mutable win_h : int;
mutable scroll_y : float;
}
(* -- Parse and build render tree -- *)
let load_content (state : app_state) (source : string) (cr : Cairo.context) =
let values = Sx_parser.parse_all source in
let navigate href =
(* Simple navigation: if href starts with / or is a relative path, reload *)
Printf.printf "[navigate] %s\n%!" href;
state.current_url <- href;
(* In a full implementation, this would trigger a re-fetch and re-render *)
in
let root = Sx_native_render.render_page ~navigate values in
Sx_native_layout.measure cr root;
let w = float_of_int state.win_w in
let h = float_of_int state.win_h -. 36. in (* subtract URL bar *)
Sx_native_layout.layout root 0. 0. w h;
state.root <- root;
state.needs_repaint <- true
(* -- Hit testing -- *)
let rec hit_test (node : node) (x : float) (y : float) : node option =
let b = node.box in
if x >= b.x && x <= b.x +. b.w && y >= b.y && y <= b.y +. b.h then begin
(* Check children in reverse order (topmost first) *)
let child_hit = List.fold_left (fun acc child ->
match acc with
| Some _ -> acc
| None -> hit_test child x y
) None (List.rev node.children) in
match child_hit with
| Some _ -> child_hit
| None -> Some node
end
else None
let handle_click (state : app_state) (root : node) (x : float) (y : float) =
(* Offset y by URL bar height for hit testing *)
let adjusted_y = y -. 36. -. state.scroll_y in
match hit_test root x adjusted_y with
| Some node ->
(match node.on_click with
| Some f -> f ()
| None ->
match node.href with
| Some href ->
Printf.printf "[click] link: %s\n%!" href;
state.current_url <- href
| None ->
Printf.printf "[click] %s at (%.0f, %.0f)\n%!" node.tag x y)
| None ->
Printf.printf "[click] miss at (%.0f, %.0f)\n%!" x y
(* -- Default demo content -- *)
let demo_sx = {|
(div :class "flex flex-col items-center gap-6 p-8 bg-stone-50"
(h1 :class "text-3xl font-bold text-stone-800" "SX Native Browser")
(p :class "text-stone-500" "Rendering s-expressions directly to pixels")
(div :class "flex gap-4 items-center"
(div :class "p-4 rounded-lg bg-white border border-stone-200 shadow"
(h3 :class "font-bold text-stone-700" "No HTML")
(p :class "text-sm text-stone-500" "This is not a web page"))
(div :class "p-4 rounded-lg bg-white border border-stone-200 shadow"
(h3 :class "font-bold text-stone-700" "No CSS")
(p :class "text-sm text-stone-500" "Tailwind classes parsed to native styles"))
(div :class "p-4 rounded-lg bg-white border border-stone-200 shadow"
(h3 :class "font-bold text-stone-700" "No JavaScript")
(p :class "text-sm text-stone-500" "The SX evaluator does everything")))
(div :class "p-6 rounded-lg bg-violet-600"
(p :class "text-white text-lg font-bold" "5,000 lines of OCaml instead of 35 million lines of browser engine")))
|}
(* -- Main -- *)
let () =
(* Parse command line *)
let source = ref "" in
let url = ref "sx://demo" in
let args = Array.to_list Sys.argv in
(match args with
| _ :: file :: _ when Sys.file_exists file ->
source := Sx_native_fetch.read_file file;
url := "file://" ^ file
| _ :: path :: _ when String.length path > 0 ->
(try
source := Sx_native_fetch.fetch_page path;
url := path
with _ ->
Printf.eprintf "Failed to fetch %s, using demo content\n%!" path;
source := demo_sx;
url := "sx://demo")
| _ ->
source := demo_sx);
(* Initialize SDL2 *)
sdl_ok (Sdl.init Sdl.Init.(video + events));
at_exit Sdl.quit;
let initial_w = 1024 in
let initial_h = 768 in
let window = sdl_ok (Sdl.create_window "SX Browser"
~x:Sdl.Window.pos_centered ~y:Sdl.Window.pos_centered
~w:initial_w ~h:initial_h
Sdl.Window.(shown + resizable + allow_highdpi)) in
let renderer = sdl_ok (Sdl.create_renderer window
~flags:Sdl.Renderer.(accelerated + presentvsync)) in
(* Create SDL texture for Cairo to draw into *)
let create_texture w h =
sdl_ok (Sdl.create_texture renderer Sdl.Pixel.format_argb8888
Sdl.Texture.access_streaming ~w ~h)
in
let texture = ref (create_texture initial_w initial_h) in
(* Create Cairo surface *)
let create_cairo_surface w h =
Cairo.Image.create Cairo.Image.ARGB32 ~w ~h
in
let surface = ref (create_cairo_surface initial_w initial_h) in
let cr = ref (Cairo.create !surface) in
(* App state *)
let state = {
current_url = !url;
root = { tag = "root"; style = default_style; children = [];
text = None; box = make_box (); href = None; on_click = None };
needs_repaint = true;
win_w = initial_w;
win_h = initial_h;
scroll_y = 0.;
} in
(* Initial load *)
load_content state !source !cr;
(* Main event loop *)
let event = Sdl.Event.create () in
let running = ref true in
while !running do
(* Process all pending events *)
while Sdl.poll_event (Some event) do
let typ = Sdl.Event.get event Sdl.Event.typ in
if typ = Sdl.Event.quit then
running := false
else if typ = Sdl.Event.key_down then begin
let scancode = Sdl.Event.get event Sdl.Event.keyboard_scancode in
if scancode = Sdl.Scancode.escape then
running := false
else if scancode = Sdl.Scancode.up then begin
state.scroll_y <- Float.min 0. (state.scroll_y +. 40.);
state.needs_repaint <- true
end
else if scancode = Sdl.Scancode.down then begin
state.scroll_y <- state.scroll_y -. 40.;
state.needs_repaint <- true
end
else if scancode = Sdl.Scancode.home then begin
state.scroll_y <- 0.;
state.needs_repaint <- true
end
end
else if typ = Sdl.Event.mouse_button_down then begin
let mx = Float.of_int (Sdl.Event.get event Sdl.Event.mouse_button_x) in
let my = Float.of_int (Sdl.Event.get event Sdl.Event.mouse_button_y) in
handle_click state state.root mx my
end
else if typ = Sdl.Event.mouse_wheel then begin
let wy = Sdl.Event.get event Sdl.Event.mouse_wheel_y in
state.scroll_y <- state.scroll_y +. (float_of_int wy *. 40.);
if state.scroll_y > 0. then state.scroll_y <- 0.;
state.needs_repaint <- true
end
else if typ = Sdl.Event.window_event then begin
let wev = Sdl.Event.get event Sdl.Event.window_event_id in
if wev = Sdl.Event.window_event_resized
|| wev = Sdl.Event.window_event_size_changed
|| wev = Sdl.Event.window_event_exposed then begin
let (new_w, new_h) = Sdl.get_window_size window in
if new_w <> state.win_w || new_h <> state.win_h then begin
state.win_w <- new_w;
state.win_h <- new_h;
(* Recreate texture and surface at new size *)
Sdl.destroy_texture !texture;
texture := create_texture new_w new_h;
Cairo.Surface.finish !surface;
surface := create_cairo_surface new_w new_h;
cr := Cairo.create !surface;
(* Re-layout *)
Sx_native_layout.measure !cr state.root;
let w = float_of_int new_w in
let h = float_of_int new_h -. 36. in
Sx_native_layout.layout state.root 0. 0. w h
end;
state.needs_repaint <- true
end
end
done;
(* Paint if needed *)
if state.needs_repaint then begin
state.needs_repaint <- false;
let w = float_of_int state.win_w in
let h = float_of_int state.win_h in
(* Apply scroll offset to root *)
state.root.box.y <- state.scroll_y;
Sx_native_paint.paint_scene !cr state.root state.current_url w h;
Cairo.Surface.flush !surface;
(* Restore root position *)
state.root.box.y <- 0.;
(* Copy Cairo surface data to SDL texture *)
let data = Cairo.Image.get_data8 !surface in
let stride = Bigarray.Array1.dim data / state.win_h in
(* Lock texture, copy data, unlock *)
(match Sdl.lock_texture !texture None Bigarray.int8_unsigned with
| Ok (pixels, _pitch) ->
let src_len = Bigarray.Array1.dim data in
let dst_len = Bigarray.Array1.dim pixels in
let copy_len = min src_len dst_len in
for i = 0 to copy_len - 1 do
Bigarray.Array1.set pixels i (Bigarray.Array1.get data i)
done;
ignore stride;
Sdl.unlock_texture !texture
| Error (`Msg e) ->
Printf.eprintf "lock_texture error: %s\n%!" e);
(* Present *)
ignore (Sdl.render_clear renderer);
ignore (Sdl.render_copy renderer !texture);
Sdl.render_present renderer
end;
Sdl.delay 16l (* ~60 fps cap *)
done;
(* Cleanup *)
Sdl.destroy_texture !texture;
Sdl.destroy_renderer renderer;
Sdl.destroy_window window