Files
endorphin-tessera-gui/src/scroll.rs
T
codex-bot 6e69c5e5ce
Verify Tessera GUI / verify (push) Failing after 7m33s
Add per-Mount scrolling with shared paint and input clipping
2026-10-21 22:05:08 +08:00

242 lines
8.0 KiB
Rust

//! 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::<u64, (f32, f32, Option<[f32; 4]>)>::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::<Mount>(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<f32, VmError> {
let Value::Number(value) = value else {
return Err(VmError::InvalidArgument(
"scroll coordinates must be numbers",
));
};
let n = value
.to_string()
.parse::<f32>()
.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::<Mount>(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::<Mount>(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::<Mount>(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<u64> {
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
}