use crate::raster::Viewport;
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
pub struct WasmCache<V> {
map: HashMap<u64, V>,
}
impl<V> Default for WasmCache<V> {
fn default() -> Self {
Self::new()
}
}
impl<V> WasmCache<V> {
pub fn new() -> Self {
Self { map: HashMap::new() }
}
pub fn content_key(bytes: &[u8]) -> u64 {
let mut h = DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
pub fn get(&self, key: u64) -> Option<&V> {
self.map.get(&key)
}
pub fn insert(&mut self, key: u64, value: V) {
self.map.insert(key, value);
}
pub fn contains(&self, key: u64) -> bool {
self.map.contains_key(&key)
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
}
pub struct Module<H> {
pub handle: H,
pub viewport: Viewport,
}
#[derive(Clone, Copy, Debug)]
pub struct ComposeBudget {
pub max_children: usize,
pub max_bytes_per_child: usize,
pub max_total_bytes: usize,
}
impl ComposeBudget {
pub fn v1() -> Self {
Self { max_children: 8, max_bytes_per_child: 16 * 1024, max_total_bytes: 64 * 1024 }
}
pub fn admit(&self, count: usize, total_bytes: usize, child_bytes: usize) -> Result<(), String> {
if count >= self.max_children {
return Err(format!("compose: at the {}-child cap", self.max_children));
}
if child_bytes > self.max_bytes_per_child {
return Err(format!(
"compose: child is {child_bytes} bytes, over the {}-byte per-child cap",
self.max_bytes_per_child
));
}
if total_bytes.saturating_add(child_bytes) > self.max_total_bytes {
return Err(format!(
"compose: mounting {child_bytes} more bytes would exceed the {}-byte total cap",
self.max_total_bytes
));
}
Ok(())
}
}
pub fn grid_viewports(n: usize, fb_w: i32, fb_h: i32) -> Vec<Viewport> {
if n == 0 {
return Vec::new();
}
let cols = (n as f64).sqrt().ceil() as i32;
let rows = (n as i32 + cols - 1) / cols; let (cw, ch) = (fb_w / cols, fb_h / rows);
(0..n as i32)
.map(|i| Viewport { ox: (i % cols) * cw, oy: (i / cols) * ch, w: cw, h: ch })
.collect()
}
pub struct Pending<H> {
ops: Vec<Op<H>>,
}
enum Op<H> {
Spawn(Module<H>),
Close(usize),
SetViewport(usize, Viewport),
}
impl<H> Pending<H> {
fn new() -> Self {
Self { ops: Vec::new() }
}
pub fn spawn(&mut self, handle: H, viewport: Viewport) {
self.ops.push(Op::Spawn(Module { handle, viewport }));
}
pub fn close(&mut self, idx: usize) {
self.ops.push(Op::Close(idx));
}
pub fn set_viewport(&mut self, idx: usize, viewport: Viewport) {
self.ops.push(Op::SetViewport(idx, viewport));
}
fn is_empty(&self) -> bool {
self.ops.is_empty()
}
}
pub struct ModuleTable<H> {
modules: Vec<Module<H>>,
}
impl<H> Default for ModuleTable<H> {
fn default() -> Self {
Self::new()
}
}
impl<H> ModuleTable<H> {
pub fn new() -> Self {
Self { modules: Vec::new() }
}
pub fn len(&self) -> usize {
self.modules.len()
}
pub fn is_empty(&self) -> bool {
self.modules.is_empty()
}
pub fn push(&mut self, handle: H, viewport: Viewport) -> usize {
self.modules.push(Module { handle, viewport });
self.modules.len() - 1
}
pub fn tick(&mut self, mut f: impl FnMut(usize, &H, &Viewport, &mut Pending<H>)) {
let mut pending = Pending::new();
for (i, m) in self.modules.iter().enumerate() {
f(i, &m.handle, &m.viewport, &mut pending);
}
if !pending.is_empty() {
self.apply(pending);
}
}
pub fn focus_at(&self, x: i32, y: i32) -> Option<(usize, i32, i32)> {
for i in (0..self.modules.len()).rev() {
let vp = &self.modules[i].viewport;
if x >= vp.ox && y >= vp.oy && x < vp.ox + vp.w && y < vp.oy + vp.h {
return Some((i, x - vp.ox, y - vp.oy));
}
}
None
}
fn apply(&mut self, pending: Pending<H>) {
let mut closes = Vec::new();
for op in pending.ops {
match op {
Op::Spawn(m) => self.modules.push(m),
Op::SetViewport(i, vp) => {
if let Some(m) = self.modules.get_mut(i) {
m.viewport = vp;
}
}
Op::Close(i) => closes.push(i),
}
}
closes.sort_unstable();
closes.dedup();
for i in closes.into_iter().rev() {
if i < self.modules.len() {
self.modules.remove(i);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vp() -> Viewport {
Viewport::full(256, 144)
}
#[test]
fn push_adds_immediately() {
let mut t: ModuleTable<&str> = ModuleTable::new();
assert!(t.is_empty());
let i = t.push("a", vp());
assert_eq!(i, 0);
assert_eq!(t.len(), 1);
}
#[test]
fn tick_visits_every_module_with_its_index() {
let mut t: ModuleTable<i32> = ModuleTable::new();
t.push(10, vp());
t.push(20, vp());
let mut seen = Vec::new();
t.tick(|i, h, _vp, _p| seen.push((i, *h)));
assert_eq!(seen, vec![(0, 10), (1, 20)]);
}
#[test]
fn spawn_during_tick_is_deferred_then_applied() {
let mut t: ModuleTable<i32> = ModuleTable::new();
t.push(1, vp());
let mut len_seen_during = None;
t.tick(|_i, _h, _vp, p| {
len_seen_during = Some(true);
p.spawn(2, vp());
});
assert_eq!(len_seen_during, Some(true));
assert_eq!(t.len(), 2, "spawned child applied after the tick");
}
#[test]
fn tick_runs_once_per_preexisting_module_not_for_spawned() {
let mut t: ModuleTable<i32> = ModuleTable::new();
t.push(1, vp());
let mut ticks = 0;
t.tick(|_i, _h, _vp, p| {
ticks += 1;
p.spawn(99, vp()); });
assert_eq!(ticks, 1, "only the pre-existing module ticked");
assert_eq!(t.len(), 2);
}
#[test]
fn close_during_tick_applies_descending_so_indices_stay_valid() {
let mut t: ModuleTable<i32> = ModuleTable::new();
t.push(0, vp());
t.push(1, vp());
t.push(2, vp());
t.tick(|i, _h, _vp, p| {
if i == 0 || i == 2 {
p.close(i);
}
});
assert_eq!(t.len(), 1, "modules 0 and 2 removed, 1 remains");
let mut left = None;
t.tick(|_i, h, _vp, _p| left = Some(*h));
assert_eq!(left, Some(1));
}
#[test]
fn compose_budget_admits_within_caps_and_refuses_past_them() {
let b = ComposeBudget::v1();
assert!(b.admit(0, 0, 1024).is_ok());
assert!(b.admit(7, 1024, 1024).is_ok()); assert!(b.admit(8, 0, 1).is_err());
assert!(b.admit(0, 0, 16 * 1024 + 1).is_err());
assert!(b.admit(1, 60 * 1024, 8 * 1024).is_err());
assert!(b.admit(0, usize::MAX, 1).is_err());
}
#[test]
fn focus_at_routes_to_containing_module_in_local_coords() {
let mut t: ModuleTable<i32> = ModuleTable::new();
t.push(0, Viewport { ox: 0, oy: 0, w: 100, h: 100 });
t.push(1, Viewport { ox: 100, oy: 50, w: 64, h: 32 });
assert_eq!(t.focus_at(110, 60), Some((1, 10, 10)));
assert_eq!(t.focus_at(5, 5), Some((0, 5, 5)));
assert_eq!(t.focus_at(200, 200), None);
}
#[test]
fn focus_at_picks_topmost_on_overlap() {
let mut t: ModuleTable<i32> = ModuleTable::new();
t.push(0, Viewport { ox: 0, oy: 0, w: 100, h: 100 });
t.push(1, Viewport { ox: 0, oy: 0, w: 100, h: 100 }); assert_eq!(t.focus_at(10, 10), Some((1, 10, 10)));
}
#[test]
fn cache_content_key_is_deterministic_and_byte_sensitive() {
let a = WasmCache::<()>::content_key(b"abc");
assert_eq!(a, WasmCache::<()>::content_key(b"abc"));
assert_ne!(a, WasmCache::<()>::content_key(b"abd"));
assert_ne!(a, WasmCache::<()>::content_key(b""));
}
#[test]
fn republish_changes_the_key_so_no_stale_hit() {
let mut cache: WasmCache<&str> = WasmCache::new();
let k1 = WasmCache::<&str>::content_key(b"app-wasm-v1");
cache.insert(k1, "compiled-v1");
assert!(cache.contains(k1));
let k2 = WasmCache::<&str>::content_key(b"app-wasm-v2");
assert_ne!(k1, k2);
assert!(cache.get(k2).is_none(), "republished bytes must not hit the v1 entry");
assert_eq!(cache.get(k1), Some(&"compiled-v1"), "the v1 bytes still resolve to v1");
}
#[test]
fn set_viewport_during_tick_is_deferred() {
let mut t: ModuleTable<i32> = ModuleTable::new();
t.push(7, Viewport::full(256, 144));
t.tick(|i, _h, _vp, p| p.set_viewport(i, Viewport { ox: 10, oy: 20, w: 64, h: 32 }));
let mut got = None;
t.tick(|_i, _h, v, _p| got = Some(*v));
assert_eq!(got, Some(Viewport { ox: 10, oy: 20, w: 64, h: 32 }));
}
#[test]
fn grid_one_module_is_the_full_framebuffer() {
assert_eq!(grid_viewports(1, 256, 144), vec![Viewport { ox: 0, oy: 0, w: 256, h: 144 }]);
}
#[test]
fn grid_two_modules_split_side_by_side_without_overlap() {
let v = grid_viewports(2, 256, 144);
assert_eq!(v, vec![
Viewport { ox: 0, oy: 0, w: 128, h: 144 },
Viewport { ox: 128, oy: 0, w: 128, h: 144 },
]);
assert!(v[0].ox + v[0].w <= v[1].ox, "left cell ends before the right begins");
}
#[test]
fn grid_four_modules_are_a_2x2() {
let v = grid_viewports(4, 256, 144); assert_eq!(v.len(), 4);
assert_eq!(v[0], Viewport { ox: 0, oy: 0, w: 128, h: 72 });
assert_eq!(v[3], Viewport { ox: 128, oy: 72, w: 128, h: 72 });
}
#[test]
fn grid_cells_stay_in_bounds_and_zero_is_empty() {
assert!(grid_viewports(0, 256, 144).is_empty());
for n in 1..=9 {
for vp in grid_viewports(n, 256, 144) {
assert!(vp.ox >= 0 && vp.oy >= 0);
assert!(vp.ox + vp.w <= 256 && vp.oy + vp.h <= 144, "cell {vp:?} escapes the framebuffer for n={n}");
}
}
}
}