replace webview with 2D canvas builtins (winit + tiny-skia + fontdue)

Removes wry/tao WebView layer. Adds a pixel-level canvas API so Engram
programs can drive the window directly — fill rects, draw text, handle
mouse/keyboard input — enabling the UI framework to be written in Engram.

Dependencies: winit 0.29 (rwh_05), softbuffer 0.3, tiny-skia 0.11, fontdue 0.8.
This commit is contained in:
Will Anderson
2026-04-28 14:31:32 -05:00
parent b9d096ef5b
commit 36f4c222d9
5 changed files with 1256 additions and 1634 deletions
Generated
+733 -1564
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -58,3 +58,7 @@ hmac = "0.12"
sha2 = "0.10"
hex = "0.4"
base64 = "0.22"
winit = { version = "0.29", default-features = false, features = ["rwh_05"] }
softbuffer = "0.3"
tiny-skia = "0.11"
fontdue = "0.8"
+4 -2
View File
@@ -34,5 +34,7 @@ hmac = { workspace = true }
sha2 = { workspace = true }
hex = { workspace = true }
base64 = { workspace = true }
wry = "0.47"
tao = "0.30"
winit = { version = "0.29", default-features = false, features = ["rwh_05"] }
softbuffer = "0.3"
tiny-skia = "0.11"
fontdue = "0.8"
+499 -62
View File
@@ -56,26 +56,40 @@ thread_local! {
std::cell::RefCell::new(None);
}
// ── Native window / WebView state ────────────────────────────────────────────
// ── Canvas / native window state ────────────────────────────────────────────
#[derive(Default)]
struct WebViewState {
title: String,
width: u32,
height: u32,
url: Option<String>,
html: Option<String>,
pending_js: Vec<String>,
struct CanvasState {
// Window configuration
title: String,
width: u32,
height: u32,
// Drawing surface (tiny-skia pixmap)
pixmap: Option<tiny_skia::Pixmap>,
// Font for text rendering (fontdue)
font: Option<fontdue::Font>,
// Input state
mouse_x: i32,
mouse_y: i32,
mouse_buttons: u8, // bit 0 = left, bit 1 = right
// Events accumulated since last frame (JSON strings)
events: Vec<String>,
// Clip rectangle stack: (x, y, w, h)
clips: Vec<(i32, i32, u32, u32)>,
}
thread_local! {
static WV_STATE: std::cell::RefCell<WebViewState> = std::cell::RefCell::new(WebViewState {
title: "Neuron".to_string(),
width: 1200,
height: 800,
url: None,
html: None,
pending_js: Vec::new(),
static CANVAS: std::cell::RefCell<CanvasState> = std::cell::RefCell::new(CanvasState {
title: String::new(),
width: 1200,
height: 800,
pixmap: None,
font: None,
mouse_x: 0,
mouse_y: 0,
mouse_buttons: 0,
events: Vec::new(),
clips: Vec::new(),
});
}
@@ -1721,6 +1735,37 @@ fn soma_reason(query: &str) -> String {
.unwrap_or_else(|| "[soma unavailable]".into())
}
// ── Canvas helper functions ───────────────────────────────────────────────────
/// Parse "#rrggbb" or "#rrggbbaa" into tiny-skia Color.
fn parse_color(s: &str) -> tiny_skia::Color {
let s = s.trim_start_matches('#');
let r = u8::from_str_radix(s.get(0..2).unwrap_or("ff"), 16).unwrap_or(255);
let g = u8::from_str_radix(s.get(2..4).unwrap_or("ff"), 16).unwrap_or(255);
let b = u8::from_str_radix(s.get(4..6).unwrap_or("ff"), 16).unwrap_or(255);
let a = u8::from_str_radix(s.get(6..8).unwrap_or("ff"), 16).unwrap_or(255);
tiny_skia::Color::from_rgba8(r, g, b, a)
}
/// Rasterize `text` at `size` pixels using the stored fontdue font.
/// Returns (glyphs: Vec<(x_offset, metrics, bitmap)>, total_width).
fn rasterize_text(text: &str, size: f32) -> (Vec<(i32, fontdue::Metrics, Vec<u8>)>, i32) {
CANVAS.with(|cv| {
let cv = cv.borrow();
let font = match &cv.font { Some(f) => f, None => return (vec![], 0) };
let mut glyphs = Vec::new();
let mut cursor_x = 0i32;
for ch in text.chars() {
let (metrics, bitmap) = font.rasterize(ch, size);
glyphs.push((cursor_x, metrics, bitmap));
cursor_x += metrics.advance_width as i32;
}
(glyphs, cursor_x)
})
}
// ─────────────────────────────────────────────────────────────────────────────
enum BuiltinResult {
Handled,
Exit(i32),
@@ -2261,6 +2306,38 @@ fn dispatch_builtin(
continue;
}
// GET /assets/* — serve static files adjacent to the HTML file
if method == "GET" && path.starts_with("/assets/") {
let file_name = &path["/assets/".len()..];
let html_dir = GLOBAL_STATE.with(|gs| {
gs.borrow().get("__html_file__").and_then(|p| {
std::path::Path::new(p).parent().map(|d| d.to_path_buf())
})
});
let content_type = if file_name.ends_with(".png") { "image/png" }
else if file_name.ends_with(".svg") { "image/svg+xml" }
else if file_name.ends_with(".jpg") || file_name.ends_with(".jpeg") { "image/jpeg" }
else { "application/octet-stream" };
let ct_header = tiny_http::Header::from_bytes(
"Content-Type", content_type
).unwrap();
if let Some(dir) = html_dir {
let asset_path = dir.join(file_name);
if let Ok(bytes) = std::fs::read(&asset_path) {
let _ = request.respond(
tiny_http::Response::from_data(bytes)
.with_header(ct_header)
);
continue;
}
}
let _ = request.respond(
tiny_http::Response::from_string("not found")
.with_status_code(404u16)
);
continue;
}
// POST /api/chat — proxy to Neuron runtime (SSE → collect → JSON)
// Transforms landing page format { message, history, conv_id }
// into runtime format { messages: [{role,content}], conv_id }
@@ -4186,80 +4263,440 @@ fn dispatch_builtin(
BuiltinResult::Handled
}
// ── Native window / WebView builtins ──────────────────────────────────
// ── Canvas builtins ───────────────────────────────────────────────────
"window_open" => {
// window_open(title: String, width: Int, height: Int) -> Void
"canvas_open" => {
// canvas_open(title: String, width: Int, height: Int) -> Void
let height = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 800 };
let width = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 1200 };
let title = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "Neuron".to_string() };
WV_STATE.with(|st| {
let mut s = st.borrow_mut();
s.title = title;
s.width = width;
s.height = height;
let title = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "Neuron".into() };
// Load font (try SF NS, Arial, DejaVu in order)
let font_data =
std::fs::read("/System/Library/Fonts/SFNS.ttf")
.or_else(|_| std::fs::read("/System/Library/Fonts/Supplemental/Arial.ttf"))
.or_else(|_| std::fs::read("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"))
.unwrap_or_default();
let font = if font_data.is_empty() { None } else {
fontdue::Font::from_bytes(font_data.as_slice(), fontdue::FontSettings::default()).ok()
};
CANVAS.with(|cv| {
let mut cv = cv.borrow_mut();
cv.title = title;
cv.width = width;
cv.height = height;
cv.font = font;
cv.pixmap = tiny_skia::Pixmap::new(width, height);
});
stack.push(Value::Nil);
BuiltinResult::Handled
}
"webview_load_url" => {
// webview_load_url(url: String) -> Void
let url = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => { return BuiltinResult::Handled; } };
WV_STATE.with(|st| st.borrow_mut().url = Some(url));
"canvas_clear" => {
// canvas_clear(color: String) -> Void
let color_str = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#000000".into() };
let color = parse_color(&color_str);
CANVAS.with(|cv| {
if let Some(px) = cv.borrow_mut().pixmap.as_mut() {
px.fill(color);
}
});
stack.push(Value::Nil);
BuiltinResult::Handled
}
"webview_load_html" => {
// webview_load_html(html: String) -> Void
let html = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => { return BuiltinResult::Handled; } };
WV_STATE.with(|st| st.borrow_mut().html = Some(html));
"canvas_fill_rect" => {
// canvas_fill_rect(x, y, w, h: Int, color: String, radius: Int) -> Void
let radius = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, Value::Float(f) => f as f32, _ => 0.0 };
let color_str = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
let rh = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let rw = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let ry = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let rx = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let color = parse_color(&color_str);
CANVAS.with(|cv| {
let mut cv = cv.borrow_mut();
// Collect clip info before mutably borrowing pixmap
let clip_rect = cv.clips.last().copied();
if let Some(px) = cv.pixmap.as_mut() {
let mut paint = tiny_skia::Paint::default();
paint.set_color(color);
paint.anti_alias = true;
let path = {
let mut pb = tiny_skia::PathBuilder::new();
if radius > 0.0 {
let r = radius.min(rw / 2.0).min(rh / 2.0);
pb.move_to(rx + r, ry);
pb.line_to(rx + rw - r, ry);
pb.quad_to(rx + rw, ry, rx + rw, ry + r);
pb.line_to(rx + rw, ry + rh - r);
pb.quad_to(rx + rw, ry + rh, rx + rw - r, ry + rh);
pb.line_to(rx + r, ry + rh);
pb.quad_to(rx, ry + rh, rx, ry + rh - r);
pb.line_to(rx, ry + r);
pb.quad_to(rx, ry, rx + r, ry);
pb.close();
} else if let Some(rect) = tiny_skia::Rect::from_xywh(rx, ry, rw.max(0.1), rh.max(0.1)) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(path) = path {
// Apply clip if any
let clip_mask = clip_rect.and_then(|(cx, cy, cw, ch)| {
tiny_skia::Rect::from_xywh(cx as f32, cy as f32, cw as f32, ch as f32).and_then(|rect| {
let clip_path = tiny_skia::PathBuilder::from_rect(rect);
let mut mask = tiny_skia::Mask::new(px.width(), px.height())?;
mask.fill_path(&clip_path, tiny_skia::FillRule::Winding, false, tiny_skia::Transform::identity());
Some(mask)
})
});
px.fill_path(&path, &paint, tiny_skia::FillRule::Winding, tiny_skia::Transform::identity(), clip_mask.as_ref());
}
}
});
stack.push(Value::Nil);
BuiltinResult::Handled
}
"webview_eval" => {
// webview_eval(js: String) -> Void
let js = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => { return BuiltinResult::Handled; } };
WV_STATE.with(|st| st.borrow_mut().pending_js.push(js));
"canvas_stroke_rect" => {
// canvas_stroke_rect(x, y, w, h: Int, color: String, stroke_w: Int, radius: Int) -> Void
let radius = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, Value::Float(f) => f as f32, _ => 0.0 };
let stroke_w = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 1.0 };
let color_str = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
let rh = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let rw = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let ry = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let rx = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let color = parse_color(&color_str);
CANVAS.with(|cv| {
let mut cv = cv.borrow_mut();
if let Some(px) = cv.pixmap.as_mut() {
let mut paint = tiny_skia::Paint::default();
paint.set_color(color);
paint.anti_alias = true;
let mut stroke = tiny_skia::Stroke::default();
stroke.width = stroke_w;
let path = {
let mut pb = tiny_skia::PathBuilder::new();
if radius > 0.0 {
let r = radius.min(rw / 2.0).min(rh / 2.0);
pb.move_to(rx + r, ry);
pb.line_to(rx + rw - r, ry);
pb.quad_to(rx + rw, ry, rx + rw, ry + r);
pb.line_to(rx + rw, ry + rh - r);
pb.quad_to(rx + rw, ry + rh, rx + rw - r, ry + rh);
pb.line_to(rx + r, ry + rh);
pb.quad_to(rx, ry + rh, rx, ry + rh - r);
pb.line_to(rx, ry + r);
pb.quad_to(rx, ry, rx + r, ry);
pb.close();
} else if let Some(rect) = tiny_skia::Rect::from_xywh(rx, ry, rw.max(0.1), rh.max(0.1)) {
pb.push_rect(rect);
}
pb.finish()
};
if let Some(path) = path {
px.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None);
}
}
});
stack.push(Value::Nil);
BuiltinResult::Handled
}
"window_run" => {
// window_run() -> Void (never returns; exits process on window close)
use tao::event::{Event, WindowEvent};
use tao::event_loop::{ControlFlow, EventLoop};
use tao::window::WindowBuilder;
use wry::WebViewBuilder;
"canvas_line" => {
// canvas_line(x1, y1, x2, y2: Int, color: String, line_w: Int) -> Void
let line_w = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 1.0 };
let color_s = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
let y2 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let x2 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let y1 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let x1 = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 0.0 };
let color = parse_color(&color_s);
CANVAS.with(|cv| {
let mut cv = cv.borrow_mut();
if let Some(px) = cv.pixmap.as_mut() {
let mut paint = tiny_skia::Paint::default();
paint.set_color(color);
paint.anti_alias = true;
let mut stroke = tiny_skia::Stroke::default();
stroke.width = line_w;
let mut pb = tiny_skia::PathBuilder::new();
pb.move_to(x1, y1);
pb.line_to(x2, y2);
if let Some(path) = pb.finish() {
px.stroke_path(&path, &paint, &stroke, tiny_skia::Transform::identity(), None);
}
}
});
stack.push(Value::Nil);
BuiltinResult::Handled
}
let (title, width, height, url, html) = WV_STATE.with(|st| {
let s = st.borrow();
(s.title.clone(), s.width, s.height, s.url.clone(), s.html.clone())
"canvas_text" => {
// canvas_text(x, y: Int, text: String, size: Int, color: String) -> Void
let color_s = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "#ffffff".into() };
let size = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 14.0 };
let text = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => String::new() };
let y = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
let x = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
let color = parse_color(&color_s);
let (glyphs, _) = rasterize_text(&text, size);
CANVAS.with(|cv| {
let mut cv = cv.borrow_mut();
if let Some(px) = cv.pixmap.as_mut() {
let pw = px.width() as i32;
let ph = px.height() as i32;
let cr = (color.red() * 255.0) as u32;
let cg = (color.green() * 255.0) as u32;
let cb = (color.blue() * 255.0) as u32;
for (gx_off, metrics, bitmap) in &glyphs {
let gx = x + gx_off + metrics.xmin;
let gy = y - metrics.height as i32 - metrics.ymin;
for row in 0..metrics.height {
for col in 0..metrics.width {
let alpha = bitmap[row * metrics.width + col];
if alpha == 0 { continue; }
let px_x = gx + col as i32;
let px_y = gy + row as i32;
if px_x < 0 || px_y < 0 || px_x >= pw || px_y >= ph { continue; }
let idx = (px_y as usize * pw as usize + px_x as usize) * 4;
let data = px.data_mut();
let a = alpha as u32;
let ia = 255 - a;
data[idx] = ((ia * data[idx] as u32 + a * cr) / 255) as u8;
data[idx + 1] = ((ia * data[idx+1] as u32 + a * cg) / 255) as u8;
data[idx + 2] = ((ia * data[idx+2] as u32 + a * cb) / 255) as u8;
data[idx + 3] = 255;
}
}
}
}
});
stack.push(Value::Nil);
BuiltinResult::Handled
}
"canvas_text_width" => {
// canvas_text_width(text: String, size: Int) -> Int
let size = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 14.0 };
let text = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => String::new() };
let (_, w) = rasterize_text(&text, size);
stack.push(Value::Int(w as i64));
BuiltinResult::Handled
}
"canvas_text_height" => {
// canvas_text_height(size: Int) -> Int
let size = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as f32, _ => 14.0 };
stack.push(Value::Int((size * 1.2) as i64));
BuiltinResult::Handled
}
"canvas_clip" => {
// canvas_clip(x, y, w, h: Int) -> Void
let h = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 0 };
let w = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as u32, _ => 0 };
let y = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
let x = match stack.pop().unwrap_or(Value::Nil) { Value::Int(n) => n as i32, _ => 0 };
CANVAS.with(|cv| cv.borrow_mut().clips.push((x, y, w, h)));
stack.push(Value::Nil);
BuiltinResult::Handled
}
"canvas_unclip" => {
CANVAS.with(|cv| { cv.borrow_mut().clips.pop(); });
stack.push(Value::Nil);
BuiltinResult::Handled
}
"canvas_size" => {
// canvas_size() -> [Int] — [width, height]
let (w, h) = CANVAS.with(|cv| {
let cv = cv.borrow();
(cv.width as i64, cv.height as i64)
});
stack.push(Value::List(vec![Value::Int(w), Value::Int(h)]));
BuiltinResult::Handled
}
"canvas_mouse_pos" => {
// canvas_mouse_pos() -> [Int] — [x, y]
let (x, y) = CANVAS.with(|cv| {
let cv = cv.borrow();
(cv.mouse_x as i64, cv.mouse_y as i64)
});
stack.push(Value::List(vec![Value::Int(x), Value::Int(y)]));
BuiltinResult::Handled
}
"canvas_events" => {
// canvas_events() -> String — JSON array of event objects
let evts = CANVAS.with(|cv| cv.borrow().events.clone());
let result = if evts.is_empty() {
"[]".to_string()
} else {
format!("[{}]", evts.join(","))
};
stack.push(Value::Str(result));
BuiltinResult::Handled
}
"canvas_swap" => {
// canvas_swap() -> Void — no-op; canvas_run_loop handles presentation
stack.push(Value::Nil);
BuiltinResult::Handled
}
"canvas_run_loop" => {
// canvas_run_loop(draw_fn: String) -> Void (never returns)
let draw_fn = match stack.pop().unwrap_or(Value::Nil) { Value::Str(s) => s, _ => "draw".into() };
let arc_instructions = SERVE_INSTRUCTIONS.with(|si| si.borrow().clone())
.expect("canvas_run_loop: no instructions stored (call canvas_open first and ensure interpreter stored them)");
let arc_fn_table = SERVE_FN_TABLE.with(|sf| sf.borrow().clone())
.expect("canvas_run_loop: no fn_table stored");
let (title, width, height) = CANVAS.with(|cv| {
let cv = cv.borrow();
(cv.title.clone(), cv.width, cv.height)
});
let event_loop = EventLoop::new();
use winit::event::{Event, WindowEvent, MouseButton, ElementState};
use winit::event_loop::{ControlFlow, EventLoop};
use winit::keyboard::{Key, NamedKey};
use winit::window::WindowBuilder;
let event_loop = EventLoop::new().expect("failed to create event loop");
let window = WindowBuilder::new()
.with_title(title)
.with_inner_size(tao::dpi::LogicalSize::new(width, height))
.with_title(&title)
.with_inner_size(winit::dpi::LogicalSize::new(width, height))
.build(&event_loop)
.expect("failed to create window");
let mut builder = WebViewBuilder::new();
if let Some(u) = url {
builder = builder.with_url(u);
} else if let Some(h) = html {
builder = builder.with_html(h);
} else {
builder = builder.with_html("<h1>Neuron</h1>");
}
let context = unsafe { softbuffer::Context::new(&window) }.expect("softbuffer context");
let mut surface = unsafe { softbuffer::Surface::new(&context, &window) }.expect("softbuffer surface");
let _webview = builder.build(&window).expect("failed to create webview");
let mut frame_events: Vec<String> = Vec::new();
// event_loop.run() never returns (diverging fn `-> !`)
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
if let Event::WindowEvent { event: WindowEvent::CloseRequested, .. } = event {
std::process::exit(0);
let _ = event_loop.run(move |event, elwt| {
elwt.set_control_flow(ControlFlow::Poll);
match event {
Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => {
std::process::exit(0);
}
Event::WindowEvent { event: WindowEvent::Resized(physical), .. } => {
let nw = physical.width.max(1);
let nh = physical.height.max(1);
CANVAS.with(|cv| {
let mut cv = cv.borrow_mut();
cv.width = nw;
cv.height = nh;
cv.pixmap = tiny_skia::Pixmap::new(nw, nh);
});
frame_events.push(format!(r#"{{"type":"resize","w":{},"h":{}}}"#, nw, nh));
}
Event::WindowEvent { event: WindowEvent::CursorMoved { position, .. }, .. } => {
let mx = position.x as i32;
let my = position.y as i32;
CANVAS.with(|cv| {
let mut cv = cv.borrow_mut();
cv.mouse_x = mx;
cv.mouse_y = my;
});
frame_events.push(format!(r#"{{"type":"mouse_move","x":{},"y":{}}}"#, mx, my));
}
Event::WindowEvent { event: WindowEvent::MouseInput { state, button, .. }, .. } => {
let btn_str = match button { MouseButton::Left => "left", MouseButton::Right => "right", _ => "other" };
let ev_type = match state { ElementState::Pressed => "mouse_down", ElementState::Released => "mouse_up" };
let (mx, my) = CANVAS.with(|cv| { let cv = cv.borrow(); (cv.mouse_x, cv.mouse_y) });
frame_events.push(format!(r#"{{"type":"{}","x":{},"y":{},"button":"{}"}}"#, ev_type, mx, my, btn_str));
if state == ElementState::Released && button == MouseButton::Left {
frame_events.push(format!(r#"{{"type":"mouse_click","x":{},"y":{},"button":"left"}}"#, mx, my));
}
}
Event::WindowEvent { event: WindowEvent::MouseWheel { delta, .. }, .. } => {
let (dx, dy) = match delta {
winit::event::MouseScrollDelta::LineDelta(x, y) => (x as i32, y as i32),
winit::event::MouseScrollDelta::PixelDelta(p) => (p.x as i32, p.y as i32),
};
frame_events.push(format!(r#"{{"type":"scroll","dx":{},"dy":{}}}"#, dx, dy));
}
Event::WindowEvent { event: WindowEvent::KeyboardInput { event: ref kev, .. }, .. } => {
// Emit char events for printable keys
if kev.state == ElementState::Pressed {
if let Key::Character(ref s) = kev.logical_key {
let ch_str = s.as_str();
if !ch_str.chars().all(|c| c.is_control()) {
let escaped = ch_str.replace('"', "\\\"");
frame_events.push(format!(r#"{{"type":"char","char":"{}"}}"#, escaped));
}
}
}
// Emit key_down events for named keys
if kev.state == ElementState::Pressed {
let key_str = match &kev.logical_key {
Key::Named(NamedKey::Enter) => "Return",
Key::Named(NamedKey::Escape) => "Escape",
Key::Named(NamedKey::Backspace) => "Backspace",
Key::Named(NamedKey::Delete) => "Delete",
Key::Named(NamedKey::ArrowLeft) => "ArrowLeft",
Key::Named(NamedKey::ArrowRight) => "ArrowRight",
Key::Named(NamedKey::ArrowUp) => "ArrowUp",
Key::Named(NamedKey::ArrowDown) => "ArrowDown",
Key::Named(NamedKey::Tab) => "Tab",
Key::Named(NamedKey::Home) => "Home",
Key::Named(NamedKey::End) => "End",
_ => "",
};
if !key_str.is_empty() {
frame_events.push(format!(r#"{{"type":"key_down","key":"{}"}}"#, key_str));
}
}
}
Event::AboutToWait => {
// Set this frame's events, then call the Engram draw function
CANVAS.with(|cv| {
cv.borrow_mut().events = std::mem::take(&mut frame_events);
});
if let Some(&entry) = arc_fn_table.get(draw_fn.as_str()) {
run_sub_interpreter(&arc_instructions, &arc_fn_table, entry);
}
// Present the pixmap to the screen
let (w, h) = CANVAS.with(|cv| { let cv = cv.borrow(); (cv.width, cv.height) });
let nz_w = std::num::NonZeroU32::new(w.max(1)).unwrap();
let nz_h = std::num::NonZeroU32::new(h.max(1)).unwrap();
if surface.resize(nz_w, nz_h).is_ok() {
if let Ok(mut buf) = surface.buffer_mut() {
CANVAS.with(|cv| {
let cv = cv.borrow();
if let Some(px) = &cv.pixmap {
let pixels = px.data(); // RGBA bytes
for (i, chunk) in pixels.chunks_exact(4).enumerate() {
// softbuffer expects 0x00RRGGBB in native u32
buf[i] = ((chunk[0] as u32) << 16)
| ((chunk[1] as u32) << 8)
| (chunk[2] as u32);
}
}
});
let _ = buf.present();
}
}
window.request_redraw();
}
_ => {}
}
});
// event_loop.run never returns normally (exits via std::process::exit)
unreachable!()
}
_ => BuiltinResult::NotBuiltin,
+16 -6
View File
@@ -262,12 +262,22 @@ impl TypeEnv {
env.functions.insert("cursor_col".into(), str_fn(vec![i.clone()], Type::Void));
env.functions.insert("http_sse_post".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone()));
// Native window / WebView builtins
env.functions.insert("window_open".into(), str_fn(vec![s.clone(), i.clone(), i.clone()], Type::Void));
env.functions.insert("webview_load_url".into(), str_fn(vec![s.clone()], Type::Void));
env.functions.insert("webview_load_html".into(), str_fn(vec![s.clone()], Type::Void));
env.functions.insert("webview_eval".into(), str_fn(vec![s.clone()], Type::Void));
env.functions.insert("window_run".into(), str_fn(vec![], Type::Void));
// Canvas / native window builtins
env.functions.insert("canvas_open".into(), str_fn(vec![s.clone(), i.clone(), i.clone()], Type::Void));
env.functions.insert("canvas_clear".into(), str_fn(vec![s.clone()], Type::Void));
env.functions.insert("canvas_fill_rect".into(), str_fn(vec![i.clone(), i.clone(), i.clone(), i.clone(), s.clone(), i.clone()], Type::Void));
env.functions.insert("canvas_stroke_rect".into(), str_fn(vec![i.clone(), i.clone(), i.clone(), i.clone(), s.clone(), i.clone(), i.clone()], Type::Void));
env.functions.insert("canvas_line".into(), str_fn(vec![i.clone(), i.clone(), i.clone(), i.clone(), s.clone(), i.clone()], Type::Void));
env.functions.insert("canvas_text".into(), str_fn(vec![i.clone(), i.clone(), s.clone(), i.clone(), s.clone()], Type::Void));
env.functions.insert("canvas_text_width".into(), str_fn(vec![s.clone(), i.clone()], i.clone()));
env.functions.insert("canvas_text_height".into(), str_fn(vec![i.clone()], i.clone()));
env.functions.insert("canvas_clip".into(), str_fn(vec![i.clone(), i.clone(), i.clone(), i.clone()], Type::Void));
env.functions.insert("canvas_unclip".into(), str_fn(vec![], Type::Void));
env.functions.insert("canvas_size".into(), str_fn(vec![], Type::Unknown));
env.functions.insert("canvas_mouse_pos".into(), str_fn(vec![], Type::Unknown));
env.functions.insert("canvas_events".into(), str_fn(vec![], s.clone()));
env.functions.insert("canvas_swap".into(), str_fn(vec![], Type::Void));
env.functions.insert("canvas_run_loop".into(), str_fn(vec![s.clone()], Type::Void));
// Math
for name in &["math_abs","math_floor","math_ceil","math_round","math_sqrt"] {