notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
Mcrates/notedeck_wasm/api/notedeck_api.h | 17+++++++++++++----
Mcrates/notedeck_wasm/src/commands.rs | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_wasm/src/host_fns.rs | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_wasm/src/lib.rs | 196+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
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());