//! Per-occurrence scroll state and a single geometry/clip projection shared by //! native paint, source paint and input. No business object is copied. use crate::{ document::Frame, geometry::Point, layout::{self, Size}, mount::{Mount, MountTable}, ui_bridge::{UiKind, UiNode}, }; use anyhow::{Result, bail}; use endor_core::{NativeContext, ObjectRef, Value, Vm, VmError, WeakObjectRef}; use std::collections::HashMap; fn intersection(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { let x = a[0].max(b[0]); let y = a[1].max(b[1]); [ x, y, (a[0] + a[2]).min(b[0] + b[2]).max(x) - x, (a[1] + a[3]).min(b[1] + b[3]).max(y) - y, ] } pub fn project(vm: &mut Vm, table: &MountTable, root: &UiNode, frame: &mut Frame) -> Result<()> { let mut nodes = HashMap::new(); let mut work = vec![root]; while let Some(node) = work.pop() { nodes.insert(node.block_id.as_str(), node); work.extend(node.children().iter().map(|node| node.as_ref())); } let indices: HashMap<_, _> = frame .mounts .iter() .enumerate() .map(|(i, m)| (m.id, i)) .collect(); let scrolling: Vec<_> = frame .mounts .iter() .map(|m| matches!(nodes[m.block_id.as_str()].kind, UiKind::Scroll { .. })) .collect(); let mut extent: Vec<_> = frame .mounts .iter() .map(|m| { let padding = match nodes[m.block_id.as_str()].kind { UiKind::Scroll { padding, .. } => padding, _ => 0.0, }; (m.x + m.width - padding, m.y + m.height - padding) }) .collect(); for i in (0..frame.mounts.len()).rev() { let m = &frame.mounts[i]; if let Some(parent) = m.parent { let parent = indices[&parent]; let e = if scrolling[i] { (m.x + m.width, m.y + m.height) } else { extent[i] }; extent[parent].0 = extent[parent].0.max(e.0); extent[parent].1 = extent[parent].1.max(e.1); } } let mut contexts = HashMap::)>::new(); frame.commands.clear(); frame.command_clips.clear(); for (i, m) in frame.mounts.iter_mut().enumerate() { let (dx, dy, clip) = m .parent .and_then(|id| contexts.get(&id).copied()) .unwrap_or_default(); let padding = match nodes[m.block_id.as_str()].kind { UiKind::Scroll { padding, .. } => padding, _ => 0.0, }; let limits = [ (extent[i].0 + padding - m.x - m.width).max(0.0), (extent[i].1 + padding - m.y - m.height).max(0.0), ]; m.x -= dx; m.y -= dy; m.clip = clip; let mut child = (dx, dy, clip); if scrolling[i] { let weak = table .active .get(&m.id) .ok_or_else(|| anyhow::anyhow!("missing scroll Mount"))?; let owner = vm .upgrade(*weak)? .ok_or_else(|| anyhow::anyhow!("expired scroll Mount"))?; let offset = vm .payload::(owner, "Mount") .map(|mount| mount.scroll_offset); vm.release(owner)?; let offset = offset?; let x = offset[0].clamp(0.0, limits[0]); let y = offset[1].clamp(0.0, limits[1]); m.scroll = Some([x, y, limits[0], limits[1]]); let viewport = [m.x, m.y, m.width, m.height]; child = ( dx + x, dy + y, Some( clip.map(|clip| intersection(clip, viewport)) .unwrap_or(viewport), ), ); } contexts.insert(m.id, child); if let Some(command) = layout::paint_native( nodes[m.block_id.as_str()], m.x, m.y, Size { width: m.width, height: m.height, }, ) { frame.commands.push(command); frame.command_clips.push(m.clip); } } if frame.commands.len() > 10_000 { bail!("scroll paint command budget exceeded"); } Ok(()) } pub fn coordinate(value: &Value) -> Result { let Value::Number(value) = value else { return Err(VmError::InvalidArgument( "scroll coordinates must be numbers", )); }; let n = value .to_string() .parse::() .map_err(|_| VmError::InvalidArgument("invalid scroll coordinate"))?; if !n.is_finite() || n.abs() > 1_000_000.0 { return Err(VmError::InvalidArgument("scroll coordinate exceeds limits")); } Ok(n) } pub fn set( cx: &mut NativeContext<'_>, owner: ObjectRef, desktop: WeakObjectRef, point: [f32; 2], relative: bool, ) -> Result<(), VmError> { let mut mount = cx.store().payload::(owner, "Mount")?.clone(); if !mount.mounted || mount.layout.is_some() { return Err(VmError::InvalidArgument( "scroll requires committed attached Mount", )); } let bounds = mount .geometry .scroll .ok_or(VmError::InvalidArgument("Mount is not scrollable"))?; let next = if relative { [ mount.scroll_offset[0] + point[0], mount.scroll_offset[1] + point[1], ] } else { point }; mount.scroll_offset = [next[0].clamp(0.0, bounds[2]), next[1].clamp(0.0, bounds[3])]; let desktop = cx .upgrade(desktop)? .ok_or(VmError::InvalidArgument("Mount window expired"))?; // Check the normal mutation barrier even when the clamped result is equal. cx.replace_payload(owner, mount)?; cx.set_attr(desktop, "scrollRequested", Value::Bool(true)) } pub fn into_view( cx: &mut NativeContext<'_>, owner: ObjectRef, desktop: WeakObjectRef, ) -> Result<(), VmError> { let mount = cx.store().payload::(owner, "Mount")?.clone(); if !mount.mounted || mount.layout.is_some() { return Err(VmError::InvalidArgument( "scrollIntoView requires committed attached Mount", )); } let mut rect = [ mount.geometry.x, mount.geometry.y, mount.geometry.width, mount.geometry.height, ]; let mut parent = mount.parent; while let Some(weak) = parent { let Some(owner) = cx.upgrade(weak)? else { break; }; let mount = cx.store().payload::(owner, "Mount")?.clone(); parent = mount.parent; if let Some(offset) = mount.geometry.scroll { let delta = |start: f32, length: f32, viewport: f32, size: f32| { if start < viewport { start - viewport } else { (start + length - viewport - size).max(0.0) } }; let dx = delta(rect[0], rect[2], mount.geometry.x, mount.geometry.width); let dy = delta(rect[1], rect[3], mount.geometry.y, mount.geometry.height); let x = (offset[0] + dx).clamp(0.0, offset[2]); let y = (offset[1] + dy).clamp(0.0, offset[3]); rect[0] -= x - offset[0]; rect[1] -= y - offset[1]; if x != offset[0] || y != offset[1] { set(cx, owner, desktop, [x, y], false)?; } } } Ok(()) } pub fn wheel_target(frame: &Frame, point: Point, delta: [f32; 2]) -> Option { let indices: HashMap<_, _> = frame.mounts.iter().map(|mount| (mount.id, mount)).collect(); let mut current = frame .mounts .iter() .rev() .find(|mount| mount.contains(point)); while let Some(mount) = current { if mount.scroll.is_some_and(|s| { (s[0] + delta[0]).clamp(0.0, s[2]) != s[0] || (s[1] + delta[1]).clamp(0.0, s[3]) != s[1] }) { return Some(mount.id); } current = mount.parent.and_then(|id| indices.get(&id).copied()); } None }