commit 83f2822a0add86719cb164735de84043c3f6a00b
parent f9def5143208e65d5f14e8adf44c6b4f4998c15d
Author: William Casarin <jb55@jb55.com>
Date: Sun, 22 Feb 2026 14:56:53 -0800
wasm: add drawing primitives, layout queries, and extract run_wasm_frame
Add nd_draw_rect, nd_draw_circle, nd_draw_line, nd_draw_text for
absolute drawing relative to app rect origin, plus nd_available_width
and nd_available_height for layout queries. Colors packed as 0xRRGGBBAA.
Extract run_wasm_frame() to eliminate duplication between update() and
the test helper.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
4 files changed, 327 insertions(+), 52 deletions(-)
diff --git a/crates/notedeck_wasm/api/notedeck_api.h b/crates/notedeck_wasm/api/notedeck_api.h
@@ -9,6 +9,7 @@
* - New functions may be added; existing ones will not be removed
* - All parameters are i32, f32, or (const char *, int) byte buffers
* - Extended versions use the _ex suffix if needed
+ * - Colors are packed as 0xRRGGBBAA in a 32-bit int
*
* WASM module requirements:
* - Must export: void nd_update(void)
@@ -17,11 +18,19 @@
*/
/* Text & widgets */
-void nd_label(const char *text, int len);
-void nd_heading(const char *text, int len);
-int nd_button(const char *text, int len); /* returns 1 if clicked (prev frame) */
+void nd_label(const char *text, int len);
+void nd_heading(const char *text, int len);
+int nd_button(const char *text, int len); /* returns 1 if clicked (prev frame) */
/* Layout */
-void nd_add_space(float pixels);
+void nd_add_space(float pixels);
+float nd_available_width(void);
+float nd_available_height(void);
+
+/* Drawing — coordinates relative to app rect origin */
+void nd_draw_rect(float x, float y, float w, float h, int color);
+void nd_draw_circle(float cx, float cy, float r, int color);
+void nd_draw_line(float x1, float y1, float x2, float y2, float width, int color);
+void nd_draw_text(float x, float y, const char *text, int len, float size, int color);
#endif /* NOTEDECK_API_H */
diff --git a/crates/notedeck_wasm/src/commands.rs b/crates/notedeck_wasm/src/commands.rs
@@ -1,10 +1,47 @@
use std::collections::HashMap;
+#[derive(Clone)]
pub enum UiCommand {
Label(String),
Heading(String),
Button(String),
AddSpace(f32),
+ DrawRect {
+ x: f32,
+ y: f32,
+ w: f32,
+ h: f32,
+ color: u32,
+ },
+ DrawCircle {
+ cx: f32,
+ cy: f32,
+ r: f32,
+ color: u32,
+ },
+ DrawLine {
+ x1: f32,
+ y1: f32,
+ x2: f32,
+ y2: f32,
+ width: f32,
+ color: u32,
+ },
+ DrawText {
+ x: f32,
+ y: f32,
+ text: String,
+ size: f32,
+ color: u32,
+ },
+}
+
+fn color_from_u32(c: u32) -> egui::Color32 {
+ let r = ((c >> 24) & 0xFF) as u8;
+ let g = ((c >> 16) & 0xFF) as u8;
+ let b = ((c >> 8) & 0xFF) as u8;
+ let a = (c & 0xFF) as u8;
+ egui::Color32::from_rgba_unmultiplied(r, g, b, a)
}
/// Render buffered commands into egui, returning button click events.
@@ -12,6 +49,8 @@ pub enum UiCommand {
pub fn render_commands(commands: &[UiCommand], ui: &mut egui::Ui) -> HashMap<String, bool> {
let mut events = HashMap::new();
let mut button_occ: HashMap<&str, u32> = HashMap::new();
+ let origin = ui.min_rect().left_top();
+ let painter = ui.painter().clone();
for cmd in commands {
match cmd {
@@ -31,6 +70,45 @@ pub fn render_commands(commands: &[UiCommand], ui: &mut egui::Ui) -> HashMap<Str
UiCommand::AddSpace(px) => {
ui.add_space(*px);
}
+ UiCommand::DrawRect { x, y, w, h, color } => {
+ let rect = egui::Rect::from_min_size(
+ egui::pos2(origin.x + x, origin.y + y),
+ egui::vec2(*w, *h),
+ );
+ painter.rect_filled(rect, 0.0, color_from_u32(*color));
+ }
+ UiCommand::DrawCircle { cx, cy, r, color } => {
+ let center = egui::pos2(origin.x + cx, origin.y + cy);
+ painter.circle_filled(center, *r, color_from_u32(*color));
+ }
+ UiCommand::DrawLine {
+ x1,
+ y1,
+ x2,
+ y2,
+ width,
+ color,
+ } => {
+ let p1 = egui::pos2(origin.x + x1, origin.y + y1);
+ let p2 = egui::pos2(origin.x + x2, origin.y + y2);
+ painter.line_segment([p1, p2], egui::Stroke::new(*width, color_from_u32(*color)));
+ }
+ UiCommand::DrawText {
+ x,
+ y,
+ text,
+ size,
+ color,
+ } => {
+ let pos = egui::pos2(origin.x + x, origin.y + y);
+ painter.text(
+ pos,
+ egui::Align2::LEFT_TOP,
+ text,
+ egui::FontId::proportional(*size),
+ color_from_u32(*color),
+ );
+ }
}
}
diff --git a/crates/notedeck_wasm/src/host_fns.rs b/crates/notedeck_wasm/src/host_fns.rs
@@ -7,6 +7,8 @@ pub struct HostEnv {
pub commands: Vec<UiCommand>,
pub button_events: HashMap<String, bool>,
pub button_occ: HashMap<String, u32>,
+ pub available_width: f32,
+ pub available_height: f32,
}
impl HostEnv {
@@ -16,6 +18,8 @@ impl HostEnv {
commands: Vec::new(),
button_events: HashMap::new(),
button_occ: HashMap::new(),
+ available_width: 0.0,
+ available_height: 0.0,
}
}
}
@@ -33,6 +37,8 @@ fn read_wasm_str(view: &MemoryView, ptr: i32, len: i32) -> Option<String> {
pub fn create_imports(store: &mut Store, env: &FunctionEnv<HostEnv>) -> Imports {
use wasmer::Function;
+ // --- Widgets ---
+
fn nd_label(mut env: FunctionEnvMut<HostEnv>, ptr: i32, len: i32) {
let (data, store) = env.data_and_store_mut();
let memory = data.memory.as_ref().expect("memory not set");
@@ -76,12 +82,94 @@ pub fn create_imports(store: &mut Store, env: &FunctionEnv<HostEnv>) -> Imports
data.commands.push(UiCommand::AddSpace(pixels));
}
+ // --- Layout queries ---
+
+ fn nd_available_width(env: FunctionEnvMut<HostEnv>) -> f32 {
+ env.data().available_width
+ }
+
+ fn nd_available_height(env: FunctionEnvMut<HostEnv>) -> f32 {
+ env.data().available_height
+ }
+
+ // --- Drawing primitives (coordinates relative to app rect) ---
+
+ fn nd_draw_rect(mut env: FunctionEnvMut<HostEnv>, x: f32, y: f32, w: f32, h: f32, color: i32) {
+ let (data, _store) = env.data_and_store_mut();
+ data.commands.push(UiCommand::DrawRect {
+ x,
+ y,
+ w,
+ h,
+ color: color as u32,
+ });
+ }
+
+ fn nd_draw_circle(mut env: FunctionEnvMut<HostEnv>, cx: f32, cy: f32, r: f32, color: i32) {
+ let (data, _store) = env.data_and_store_mut();
+ data.commands.push(UiCommand::DrawCircle {
+ cx,
+ cy,
+ r,
+ color: color as u32,
+ });
+ }
+
+ fn nd_draw_line(
+ mut env: FunctionEnvMut<HostEnv>,
+ x1: f32,
+ y1: f32,
+ x2: f32,
+ y2: f32,
+ width: f32,
+ color: i32,
+ ) {
+ let (data, _store) = env.data_and_store_mut();
+ data.commands.push(UiCommand::DrawLine {
+ x1,
+ y1,
+ x2,
+ y2,
+ width,
+ color: color as u32,
+ });
+ }
+
+ fn nd_draw_text(
+ mut env: FunctionEnvMut<HostEnv>,
+ x: f32,
+ y: f32,
+ ptr: i32,
+ len: i32,
+ size: f32,
+ color: i32,
+ ) {
+ let (data, store) = env.data_and_store_mut();
+ let memory = data.memory.as_ref().expect("memory not set");
+ let view = memory.view(&store);
+ if let Some(text) = read_wasm_str(&view, ptr, len) {
+ data.commands.push(UiCommand::DrawText {
+ x,
+ y,
+ text,
+ size,
+ color: color as u32,
+ });
+ }
+ }
+
wasmer::imports! {
"env" => {
"nd_label" => Function::new_typed_with_env(store, env, nd_label),
"nd_heading" => Function::new_typed_with_env(store, env, nd_heading),
"nd_button" => Function::new_typed_with_env(store, env, nd_button),
"nd_add_space" => Function::new_typed_with_env(store, env, nd_add_space),
+ "nd_available_width" => Function::new_typed_with_env(store, env, nd_available_width),
+ "nd_available_height" => Function::new_typed_with_env(store, env, nd_available_height),
+ "nd_draw_rect" => Function::new_typed_with_env(store, env, nd_draw_rect),
+ "nd_draw_circle" => Function::new_typed_with_env(store, env, nd_draw_circle),
+ "nd_draw_line" => Function::new_typed_with_env(store, env, nd_draw_line),
+ "nd_draw_text" => Function::new_typed_with_env(store, env, nd_draw_text),
}
}
}
diff --git a/crates/notedeck_wasm/src/lib.rs b/crates/notedeck_wasm/src/lib.rs
@@ -74,16 +74,15 @@ fn read_app_name(
String::from_utf8(buf).ok()
}
-impl notedeck::App for WasmApp {
- fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
- // 1. Clear commands and reset occurrence counter
- {
- let data = self.env.as_mut(&mut self.store);
- data.commands.clear();
- data.button_occ.clear();
- }
+impl WasmApp {
+ /// Run one WASM frame: clear state, call nd_update, return collected commands.
+ fn run_wasm_frame(&mut self, available: egui::Vec2) -> Vec<commands::UiCommand> {
+ let data = self.env.as_mut(&mut self.store);
+ data.commands.clear();
+ data.button_occ.clear();
+ data.available_width = available.x;
+ data.available_height = available.y;
- // 2. Run WASM — host functions push commands
let nd_update = self
.instance
.exports
@@ -93,16 +92,16 @@ impl notedeck::App for WasmApp {
tracing::error!("WASM nd_update error: {e}");
}
- // 3. Take commands and render with real UI
- let cmds = {
- let data = self.env.as_mut(&mut self.store);
- std::mem::take(&mut data.commands)
- };
- let new_events = commands::render_commands(&cmds, ui);
+ let data = self.env.as_mut(&mut self.store);
+ std::mem::take(&mut data.commands)
+ }
+}
- // 4. Store events for next frame
+impl notedeck::App for WasmApp {
+ fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse {
+ let cmds = self.run_wasm_frame(ui.available_size());
+ let new_events = commands::render_commands(&cmds, ui);
self.env.as_mut(&mut self.store).button_events = new_events;
-
AppResponse::none()
}
}
@@ -125,37 +124,8 @@ mod tests {
let ctx = egui::Context::default();
let _ = ctx.run(egui::RawInput::default(), |ctx| {
egui::CentralPanel::default().show(ctx, |ui| {
- // Clear + reset
- {
- let data = app.env.as_mut(&mut app.store);
- data.commands.clear();
- data.button_occ.clear();
- }
-
- // Run WASM
- let nd_update = app
- .instance
- .exports
- .get_function("nd_update")
- .expect("nd_update");
- nd_update.call(&mut app.store, &[]).expect("nd_update ok");
-
- // Grab commands before rendering
- let cmds = {
- let data = app.env.as_mut(&mut app.store);
- std::mem::take(&mut data.commands)
- };
- result_cmds = cmds
- .iter()
- .map(|c| match c {
- UiCommand::Label(t) => UiCommand::Label(t.clone()),
- UiCommand::Heading(t) => UiCommand::Heading(t.clone()),
- UiCommand::Button(t) => UiCommand::Button(t.clone()),
- UiCommand::AddSpace(px) => UiCommand::AddSpace(*px),
- })
- .collect();
-
- // Render and collect events
+ let cmds = app.run_wasm_frame(ui.available_size());
+ result_cmds = cmds.clone();
let new_events = commands::render_commands(&cmds, ui);
app.env.as_mut(&mut app.store).button_events = new_events;
});
@@ -358,6 +328,136 @@ mod tests {
}
#[test]
+ fn available_width_returns_value() {
+ // Module that reads nd_available_width and stores it in a global.
+ let mut app = app_from_wat(
+ r#"(module
+ (import "env" "nd_available_width" (func $nd_available_width (result f32)))
+ (memory (export "memory") 1)
+ (global (export "width_result") (mut f32) (f32.const 0.0))
+ (func (export "nd_update")
+ (global.set 0 (call $nd_available_width))
+ )
+ )"#,
+ );
+
+ run_update(&mut app);
+ let val = app
+ .instance
+ .exports
+ .get_global("width_result")
+ .unwrap()
+ .get(&mut app.store);
+ let result = match val {
+ wasmer::Value::F32(f) => f,
+ wasmer::Value::F64(f) => f as f32,
+ other => panic!("unexpected value type: {:?}", other),
+ };
+ // In headless egui, available width is positive
+ assert!(result > 0.0, "available_width should be positive");
+ }
+
+ #[test]
+ fn draw_rect_produces_command() {
+ let mut app = app_from_wat(
+ r#"(module
+ (import "env" "nd_draw_rect" (func $nd_draw_rect (param f32 f32 f32 f32 i32)))
+ (memory (export "memory") 1)
+ (func (export "nd_update")
+ (call $nd_draw_rect
+ (f32.const 10.0) (f32.const 20.0)
+ (f32.const 100.0) (f32.const 50.0)
+ (i32.const 0xFF0000FF))
+ )
+ )"#,
+ );
+ let cmds = run_update(&mut app);
+ assert_eq!(cmds.len(), 1);
+ assert!(matches!(
+ &cmds[0],
+ UiCommand::DrawRect { x, y, w, h, color }
+ if (*x - 10.0).abs() < f32::EPSILON
+ && (*y - 20.0).abs() < f32::EPSILON
+ && (*w - 100.0).abs() < f32::EPSILON
+ && (*h - 50.0).abs() < f32::EPSILON
+ && *color == 0xFF0000FF
+ ));
+ }
+
+ #[test]
+ fn draw_circle_produces_command() {
+ let mut app = app_from_wat(
+ r#"(module
+ (import "env" "nd_draw_circle" (func $nd_draw_circle (param f32 f32 f32 i32)))
+ (memory (export "memory") 1)
+ (func (export "nd_update")
+ (call $nd_draw_circle
+ (f32.const 50.0) (f32.const 50.0)
+ (f32.const 25.0)
+ (i32.const 0x00FF00FF))
+ )
+ )"#,
+ );
+ let cmds = run_update(&mut app);
+ assert_eq!(cmds.len(), 1);
+ assert!(matches!(
+ &cmds[0],
+ UiCommand::DrawCircle { cx, cy, r, color }
+ if (*cx - 50.0).abs() < f32::EPSILON
+ && (*cy - 50.0).abs() < f32::EPSILON
+ && (*r - 25.0).abs() < f32::EPSILON
+ && *color == 0x00FF00FF
+ ));
+ }
+
+ #[test]
+ fn draw_line_produces_command() {
+ let mut app = app_from_wat(
+ r#"(module
+ (import "env" "nd_draw_line" (func $nd_draw_line (param f32 f32 f32 f32 f32 i32)))
+ (memory (export "memory") 1)
+ (func (export "nd_update")
+ (call $nd_draw_line
+ (f32.const 0.0) (f32.const 0.0)
+ (f32.const 100.0) (f32.const 100.0)
+ (f32.const 2.0)
+ (i32.const 0xFFFFFFFF))
+ )
+ )"#,
+ );
+ let cmds = run_update(&mut app);
+ assert_eq!(cmds.len(), 1);
+ assert!(
+ matches!(&cmds[0], UiCommand::DrawLine { width, .. } if (*width - 2.0).abs() < f32::EPSILON)
+ );
+ }
+
+ #[test]
+ fn draw_text_produces_command() {
+ let mut app = app_from_wat(
+ r#"(module
+ (import "env" "nd_draw_text" (func $nd_draw_text (param f32 f32 i32 i32 f32 i32)))
+ (memory (export "memory") 1)
+ (data (i32.const 0) "hello")
+ (func (export "nd_update")
+ (call $nd_draw_text
+ (f32.const 10.0) (f32.const 20.0)
+ (i32.const 0) (i32.const 5)
+ (f32.const 16.0)
+ (i32.const 0xFFFFFFFF))
+ )
+ )"#,
+ );
+ let cmds = run_update(&mut app);
+ assert_eq!(cmds.len(), 1);
+ assert!(matches!(
+ &cmds[0],
+ UiCommand::DrawText { text, size, .. }
+ if text == "hello" && (*size - 16.0).abs() < f32::EPSILON
+ ));
+ }
+
+ #[test]
fn from_bytes_rejects_invalid_wasm() {
let result = WasmApp::from_bytes(b"not wasm");
assert!(result.is_err());