532 lines
22 KiB
Rust
532 lines
22 KiB
Rust
//! Continuous numeric scrubbing across native monitor edges.
|
|
|
|
use egui::{Context, Event, Id, PointerButton, Pos2, RawInput, Response, Vec2};
|
|
#[cfg(any(not(target_arch = "wasm32"), test))]
|
|
use egui::{Rect, pos2, vec2};
|
|
|
|
#[derive(Clone, Default)]
|
|
struct State {
|
|
numeric: Option<(Id, u64)>,
|
|
offset: Vec2,
|
|
pending: Option<(Pos2, Pos2)>,
|
|
disabled: bool,
|
|
}
|
|
|
|
fn state_id() -> Id {
|
|
Id::new("numeric-pointer-wrap")
|
|
}
|
|
|
|
/// Opt in only number scrubs, never object, keyframe, panel or scrollbar drags.
|
|
pub(crate) fn track(response: &Response) {
|
|
if response.dragged_by(PointerButton::Primary) {
|
|
let pass = response.ctx.cumulative_pass_nr();
|
|
response.ctx.data_mut(|d| {
|
|
let state = d.get_temp_mut_or_default::<State>(state_id());
|
|
state.numeric = Some((response.id, pass));
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Keep egui's drag coordinates continuous while the visible native pointer wraps.
|
|
/// Rebase only when the OS acknowledges a successful warp: queued events from before
|
|
/// it must not be interpreted as another monitor-width movement. A coalesced event
|
|
/// may include a little real movement beyond the requested destination.
|
|
/// Returns whether a numeric gesture ended, so the app can close its Undo group. `injected`:
|
|
/// this frame carries pointer events from the control channel (an agent's `ui.drag`), whose
|
|
/// gesture must never move the user's real cursor.
|
|
pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput, injected: bool) -> bool {
|
|
let lost_focus = !raw.focused || raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false)));
|
|
let (down, last_pos, modifiers) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers));
|
|
ctx.data_mut(|d| {
|
|
let state = d.get_temp_mut_or_default::<State>(state_id());
|
|
if lost_focus
|
|
&& down
|
|
&& state.numeric.is_some()
|
|
&& let Some(pos) = last_pos
|
|
{
|
|
// The OS may deliver the eventual button-up to another app. Explicitly
|
|
// finish this scrub so focus returning cannot restart it or leave Undo merged.
|
|
raw.events.push(Event::PointerButton { pos: pos - state.offset, button: PointerButton::Primary, pressed: false, modifiers });
|
|
}
|
|
let touch = injected || raw.events.iter().any(|e| matches!(e, Event::Touch { .. }));
|
|
if lost_focus
|
|
|| touch
|
|
|| raw.events.iter().any(|e| matches!(e, Event::WindowFocused(false) | Event::PointerButton { button: PointerButton::Primary, .. }))
|
|
{
|
|
let ended = state.numeric.is_some();
|
|
*state = State { disabled: touch, ..Default::default() };
|
|
return ended;
|
|
}
|
|
if state.numeric.is_none() {
|
|
return false;
|
|
}
|
|
// Mouse capture can report leaving the client area mid-scrub. Keep the
|
|
// drag's previous coordinate until its next captured movement arrives.
|
|
raw.events.retain(|e| !matches!(e, Event::PointerGone));
|
|
for event in &mut raw.events {
|
|
let p = match event {
|
|
Event::PointerMoved(p) | Event::PointerButton { pos: p, .. } => p,
|
|
_ => continue,
|
|
};
|
|
if let Some((from, to)) = state.pending
|
|
&& p.distance_sq(to) < p.distance_sq(from)
|
|
{
|
|
state.offset += from - to;
|
|
state.pending = None;
|
|
}
|
|
*p += state.offset;
|
|
}
|
|
false
|
|
})
|
|
}
|
|
|
|
#[cfg(any(not(target_arch = "wasm32"), test))]
|
|
fn wrap_target(p: Pos2, bounds: Rect) -> Option<Pos2> {
|
|
if !p.is_finite() || !bounds.is_finite() || bounds.width() < 32.0 || bounds.height() < 32.0 {
|
|
return None;
|
|
}
|
|
let axis = |v: f32, lo: f32, hi: f32| {
|
|
if v <= lo + 2.0 {
|
|
hi - 8.0
|
|
} else if v >= hi - 2.0 {
|
|
lo + 8.0
|
|
} else {
|
|
v
|
|
}
|
|
};
|
|
let to = pos2(axis(p.x, bounds.min.x, bounds.max.x), axis(p.y, bounds.min.y, bounds.max.y));
|
|
(to != p).then_some(to)
|
|
}
|
|
|
|
#[cfg(any(not(target_arch = "wasm32"), test))]
|
|
fn finish_frame(ctx: &Context, bounds: Rect, warp: impl FnOnce(Pos2) -> Option<Pos2>) {
|
|
let state = ctx.data(|d| d.get_temp::<State>(state_id())).unwrap_or_default();
|
|
let active = state.numeric.is_some_and(|(id, pass)| pass == ctx.cumulative_pass_nr() && ctx.is_being_dragged(id));
|
|
let (down, focused, virtual_pos) = ctx.input(|i| (i.pointer.primary_down(), i.focused, i.pointer.latest_pos()));
|
|
if !active || !down || !focused {
|
|
ctx.data_mut(|d| d.remove::<State>(state_id()));
|
|
return;
|
|
}
|
|
if state.disabled || state.pending.is_some() {
|
|
return;
|
|
}
|
|
let Some(from) = virtual_pos.map(|p| p - state.offset) else { return };
|
|
let Some(to) = wrap_target(from, bounds) else { return };
|
|
let destination = warp(to);
|
|
ctx.data_mut(|d| {
|
|
let state = d.get_temp_mut_or_default::<State>(state_id());
|
|
if let Some(to) = destination {
|
|
state.pending = Some((from, to));
|
|
} else {
|
|
// Unsupported backends keep ordinary dragging; do not retry/log every frame.
|
|
state.disabled = true;
|
|
}
|
|
});
|
|
if destination.is_some() {
|
|
ctx.request_repaint();
|
|
}
|
|
}
|
|
|
|
/// Native monitor coordinates are physical pixels; egui input uses UI points.
|
|
/// Use the monitor containing the original press, including negative desktop origins.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub(crate) fn finish_native(ctx: &Context, frame: &eframe::Frame) {
|
|
let pass = ctx.cumulative_pass_nr();
|
|
if !ctx.data(|d| d.get_temp::<State>(state_id()).is_some_and(|s| s.numeric.is_some_and(|(_, p)| p == pass))) {
|
|
ctx.data_mut(|d| d.remove::<State>(state_id()));
|
|
return;
|
|
}
|
|
let Some(window) = frame.winit_window() else { return };
|
|
let Ok(origin) = window.inner_position() else { return };
|
|
let ppp = ctx.pixels_per_point();
|
|
if !ppp.is_finite() || ppp <= 0.0 {
|
|
return;
|
|
}
|
|
let Some(press) = ctx.input(|i| i.pointer.press_origin()) else { return };
|
|
let screen_press = press * ppp + vec2(origin.x as f32, origin.y as f32);
|
|
let monitor = window
|
|
.available_monitors()
|
|
.find(|m| {
|
|
let p = m.position();
|
|
let s = m.size();
|
|
Rect::from_min_size(pos2(p.x as f32, p.y as f32), vec2(s.width as f32, s.height as f32)).contains(screen_press)
|
|
})
|
|
.or_else(|| window.current_monitor());
|
|
let Some(monitor) = monitor else { return };
|
|
let p = monitor.position();
|
|
let s = monitor.size();
|
|
let bounds = Rect::from_min_size(
|
|
pos2((p.x as f64 - origin.x as f64) as f32, (p.y as f64 - origin.y as f64) as f32) / ppp,
|
|
vec2(s.width as f32, s.height as f32) / ppp,
|
|
);
|
|
finish_frame(ctx, bounds, |to| {
|
|
// Reuse winit's physical position type obtained above, now client-relative.
|
|
let mut target = origin;
|
|
target.x = (to.x * ppp).round() as i32;
|
|
target.y = (to.y * ppp).round() as i32;
|
|
match window.set_cursor_position(target) {
|
|
// Remember the actual device pixel to avoid fractional-DPI drift on every wrap.
|
|
Ok(()) => Some(pos2(target.x as f32 / ppp, target.y as f32 / ppp)),
|
|
Err(e) => {
|
|
log::warn!("numeric cursor wrap is unavailable: {e}");
|
|
None
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use egui::{Event, Id, Modifiers, PointerButton, pos2, vec2};
|
|
|
|
struct Scrub {
|
|
ctx: Context,
|
|
value: f64,
|
|
bounds: Rect,
|
|
time: f64,
|
|
accept_warp: bool,
|
|
warps: Vec<Pos2>,
|
|
standard: bool,
|
|
ordinary: bool,
|
|
angle: bool,
|
|
angle_rects: Option<(Rect, Rect)>,
|
|
modifiers: Modifiers,
|
|
injected: bool,
|
|
}
|
|
|
|
impl Scrub {
|
|
fn new() -> Self {
|
|
Self {
|
|
ctx: Context::default(),
|
|
value: 0.0,
|
|
bounds: Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0)),
|
|
time: 0.0,
|
|
accept_warp: true,
|
|
warps: Vec::new(),
|
|
standard: false,
|
|
ordinary: false,
|
|
angle: false,
|
|
angle_rects: None,
|
|
modifiers: Modifiers::NONE,
|
|
injected: false,
|
|
}
|
|
}
|
|
|
|
fn frame(&mut self, events: Vec<Event>, focused: bool) {
|
|
self.time += 0.02;
|
|
let mut raw = RawInput {
|
|
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
|
time: Some(self.time),
|
|
events,
|
|
focused,
|
|
..Default::default()
|
|
};
|
|
raw.events.push(Event::ModifiersChanged(self.modifiers));
|
|
prepare_input(&self.ctx, &mut raw, self.injected);
|
|
let mut out = self.ctx.run_ui(raw, |ui| {
|
|
if self.standard {
|
|
ui.put(
|
|
Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)),
|
|
crate::widgets::drag_value(egui::DragValue::new(&mut self.value).speed(1.0).max_decimals(3)),
|
|
);
|
|
} else if self.ordinary {
|
|
ui.interact(Rect::from_min_size(pos2(100.0, 100.0), vec2(60.0, 20.0)), Id::new("ordinary"), egui::Sense::drag());
|
|
} else if self.angle {
|
|
let (rr, dr, value) = crate::panels::fx_widgets::angle_field(
|
|
ui,
|
|
pos2(100.0, 100.0),
|
|
Id::new("scrub"),
|
|
self.value,
|
|
1,
|
|
&crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
|
|
);
|
|
self.angle_rects = Some((rr, dr));
|
|
if let Some(v) = value {
|
|
self.value = v;
|
|
}
|
|
} else {
|
|
let (_, value, _) = crate::widgets::hot_number_at(
|
|
ui,
|
|
pos2(100.0, 100.0),
|
|
Id::new("scrub"),
|
|
self.value,
|
|
1.0,
|
|
(-1e9, 1e9),
|
|
1,
|
|
"",
|
|
&crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
|
|
);
|
|
if let Some(v) = value {
|
|
self.value = v;
|
|
}
|
|
}
|
|
finish_frame(ui.ctx(), self.bounds, |p| {
|
|
self.warps.push(p);
|
|
self.accept_warp.then_some(p)
|
|
});
|
|
});
|
|
out.textures_delta.clear();
|
|
}
|
|
|
|
fn move_to(&mut self, p: Pos2) {
|
|
self.frame(vec![Event::PointerMoved(p)], true);
|
|
}
|
|
|
|
fn begin(&mut self) {
|
|
self.frame(vec![], true);
|
|
let p = pos2(105.0, 108.0);
|
|
self.frame(
|
|
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }],
|
|
true,
|
|
);
|
|
self.move_to(p + vec2(10.0, 0.0));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn angle_degrees_continue_across_zero_and_repeated_monitor_wraps() {
|
|
for initial in [0.0, 359.5, -359.5, 720.0, -720.0] {
|
|
for direction in [-1.0, 1.0] {
|
|
let mut s = Scrub::new();
|
|
s.angle = true;
|
|
s.value = initial;
|
|
s.frame(vec![], true);
|
|
let p = s.angle_rects.unwrap().1.center();
|
|
s.frame(
|
|
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }],
|
|
true,
|
|
);
|
|
let mut last = p;
|
|
let mut expected = initial;
|
|
// Feed small real movements, including zero/whole-turn crossings, then
|
|
// the synthetic OS warp. Every real pixel contributes exactly once.
|
|
for step in 0..800 {
|
|
let direction = if step < 400 { direction } else { -direction };
|
|
let next = pos2((last.x + direction * 13.0).clamp(1.0, 799.0), last.y);
|
|
expected += f64::from(next.x - last.x) * 0.5;
|
|
let count = s.warps.len();
|
|
s.move_to(next);
|
|
assert!((s.value - expected).abs() < 1e-6, "initial={initial}, direction={direction}, actual={}, expected={expected}", s.value);
|
|
last = next;
|
|
if s.warps.len() > count {
|
|
last = *s.warps.last().unwrap();
|
|
s.move_to(last);
|
|
assert!((s.value - expected).abs() < 1e-6, "the teleport must not reset the angle");
|
|
}
|
|
}
|
|
assert!(s.warps.len() >= 5);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn numeric_drag_wraps_repeatedly_without_counting_the_teleport() {
|
|
let mut s = Scrub::new();
|
|
s.begin();
|
|
for _ in 0..5 {
|
|
s.move_to(pos2(799.0, 108.0));
|
|
let target = *s.warps.last().expect("numeric drag must wrap at the monitor edge");
|
|
assert!(target.x < 16.0);
|
|
let before = s.value;
|
|
s.move_to(target);
|
|
assert_eq!(s.value, before, "the warp itself must not change the value");
|
|
s.move_to(target + vec2(20.0, 0.0));
|
|
assert_eq!(s.value, before + 20.0);
|
|
}
|
|
assert_eq!(s.warps.len(), 5);
|
|
s.move_to(pos2(0.0, 108.0));
|
|
let target = *s.warps.last().unwrap();
|
|
assert!(target.x > 780.0);
|
|
let before = s.value;
|
|
s.move_to(target - vec2(3.0, 0.0));
|
|
assert_eq!(s.value, before - 3.0, "coalesced movement after a left-edge warp is preserved");
|
|
}
|
|
|
|
/// An agent's `ui.drag` over the control channel scrubs the value but never moves the user's
|
|
/// real cursor, even at the window's (monitor's) edge.
|
|
#[test]
|
|
fn injected_drags_never_warp_the_real_cursor() {
|
|
let mut s = Scrub::new();
|
|
s.injected = true;
|
|
s.begin();
|
|
s.move_to(pos2(799.0, 108.0));
|
|
assert!(s.warps.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn normal_pointer_movement_does_not_warp() {
|
|
let mut s = Scrub::new();
|
|
s.move_to(pos2(799.0, 108.0));
|
|
assert!(s.warps.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn standard_editor_wraps_at_top_bottom_and_corner_without_a_value_jump() {
|
|
let mut s = Scrub::new();
|
|
s.standard = true;
|
|
s.begin();
|
|
for edge in [pos2(200.0, 0.0), pos2(200.0, 599.0), pos2(799.0, 599.0)] {
|
|
s.move_to(edge);
|
|
let target = *s.warps.last().unwrap();
|
|
assert_ne!(target, edge);
|
|
let before = s.value;
|
|
s.move_to(target);
|
|
assert_eq!(s.value, before);
|
|
s.move_to(target + vec2(5.0, -5.0));
|
|
assert_eq!(s.value, before + 10.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn release_and_focus_loss_end_wrapping_and_restore_normal_hit_testing() {
|
|
for lose_focus in [false, true] {
|
|
let mut s = Scrub::new();
|
|
s.begin();
|
|
s.move_to(pos2(799.0, 108.0));
|
|
let target = *s.warps.last().unwrap();
|
|
s.move_to(target);
|
|
let value = s.value;
|
|
let events = if lose_focus {
|
|
vec![Event::WindowFocused(false)]
|
|
} else {
|
|
vec![Event::PointerButton { pos: target, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]
|
|
};
|
|
s.frame(events, !lose_focus);
|
|
s.move_to(pos2(799.0, 108.0));
|
|
assert_eq!(s.warps.len(), 1);
|
|
assert_eq!(s.value, value);
|
|
assert_eq!(s.ctx.input(|i| i.pointer.latest_pos()), Some(pos2(799.0, 108.0)));
|
|
s.begin();
|
|
s.move_to(pos2(799.0, 108.0));
|
|
assert_eq!(s.warps.len(), 2, "the next independent drag still wraps");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn object_drag_does_not_wrap_and_failed_warp_is_not_retried() {
|
|
let mut s = Scrub::new();
|
|
s.ordinary = true;
|
|
s.begin();
|
|
s.move_to(pos2(799.0, 108.0));
|
|
assert!(s.warps.is_empty());
|
|
|
|
let mut s = Scrub::new();
|
|
s.accept_warp = false;
|
|
s.begin();
|
|
s.move_to(pos2(799.0, 108.0));
|
|
for _ in 0..3 {
|
|
s.move_to(pos2(799.0, 108.0));
|
|
}
|
|
assert_eq!(s.warps.len(), 1);
|
|
let value = s.value;
|
|
s.move_to(pos2(779.0, 108.0));
|
|
assert_eq!(s.value, value - 20.0);
|
|
}
|
|
|
|
#[test]
|
|
fn modifiers_keep_their_sensitivity_after_wrapping() {
|
|
for (modifiers, multiplier) in [(Modifiers::SHIFT, 10.0), (Modifiers::COMMAND, 0.1)] {
|
|
let mut s = Scrub::new();
|
|
s.modifiers = modifiers;
|
|
s.begin();
|
|
s.move_to(pos2(799.0, 108.0));
|
|
let target = *s.warps.last().unwrap();
|
|
let value = s.value;
|
|
s.move_to(target + vec2(10.0, 0.0));
|
|
assert!((s.value - value - 10.0 * multiplier).abs() < 1e-8);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn monitor_rect_can_extend_beyond_the_app_and_have_a_negative_origin() {
|
|
let mut s = Scrub::new();
|
|
s.bounds = Rect::from_min_size(pos2(-200.0, -100.0), vec2(1200.0, 800.0));
|
|
s.begin();
|
|
s.move_to(pos2(999.0, 108.0));
|
|
let target = *s.warps.last().unwrap();
|
|
assert_eq!(target, pos2(-192.0, 108.0));
|
|
let value = s.value;
|
|
s.frame(vec![Event::PointerGone, Event::PointerMoved(target)], true);
|
|
assert_eq!(s.value, value);
|
|
s.move_to(target + vec2(10.0, 0.0));
|
|
assert_eq!(s.value, value + 10.0);
|
|
}
|
|
|
|
#[test]
|
|
fn real_properties_and_angle_drags_keep_each_gesture_in_one_undo_step() {
|
|
use crate::{EffectcraftApp, dock::PanelKind};
|
|
use effectcraft_engine::project::LayerId;
|
|
use eframe::App;
|
|
use serde_json::json;
|
|
|
|
for angle in [false, true] {
|
|
let mut session = effectcraft_host::session();
|
|
session.execute("comp.new", json!({"width": 32, "height": 16})).unwrap();
|
|
let lid = session.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
|
let path = if angle { "transform/rotation" } else { "transform/position" };
|
|
let prop = session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.prop(path).unwrap();
|
|
let uid = prop.uid;
|
|
let original = prop.value.clone();
|
|
let undo = session.history.undo.len();
|
|
let panel = PanelKind::Properties;
|
|
let id = if angle { format!("properties.prop.{uid}.value") } else { format!("properties.prop.{uid}.value.0") };
|
|
let mut app = EffectcraftApp::new(session);
|
|
app.show_panel(panel);
|
|
app.toggle_maximize(panel);
|
|
let ctx = Context::default();
|
|
let bounds = Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0));
|
|
let mut frame = eframe::Frame::_new_kittest();
|
|
let mut time = 0.0;
|
|
let mut step = |app: &mut EffectcraftApp, events| {
|
|
time += 1.0 / 60.0;
|
|
let mut raw = RawInput { screen_rect: Some(bounds), time: Some(time), events, ..Default::default() };
|
|
app.raw_input_hook(&ctx, &mut raw);
|
|
let mut warp = None;
|
|
let mut out = ctx.run_ui(raw, |ui| {
|
|
app.logic(ui.ctx(), &mut frame);
|
|
app.ui(ui, &mut frame);
|
|
finish_frame(ui.ctx(), bounds, |p| {
|
|
warp = Some(p);
|
|
Some(p)
|
|
});
|
|
});
|
|
out.textures_delta.clear();
|
|
warp
|
|
};
|
|
for _ in 0..4 {
|
|
step(&mut app, vec![]);
|
|
}
|
|
for gesture in 1..=2 {
|
|
let direction = if gesture == 1 { -1.0 } else { 1.0 };
|
|
let r = app.auto.find(&id).unwrap().rect;
|
|
let p = pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0);
|
|
step(
|
|
&mut app,
|
|
vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }],
|
|
);
|
|
step(&mut app, vec![Event::PointerMoved(p + vec2(direction * 10.0, 0.0))]);
|
|
let mut last = p;
|
|
for _ in 0..3 {
|
|
let edge = if direction < 0.0 { 1.0 } else { 1599.0 };
|
|
let target = step(&mut app, vec![Event::PointerMoved(pos2(edge, p.y))]).expect("real property scrubs wrap");
|
|
let before = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.clone();
|
|
step(&mut app, vec![Event::PointerMoved(target)]);
|
|
assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, before);
|
|
last = target + vec2(direction * 10.0, 0.0);
|
|
step(&mut app, vec![Event::PointerMoved(last)]);
|
|
if angle {
|
|
let value = app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value.as_f64();
|
|
assert!((value - before.as_f64() - f64::from(direction) * 5.0).abs() < 1e-6);
|
|
}
|
|
}
|
|
step(&mut app, vec![Event::PointerButton { pos: last, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]);
|
|
assert_eq!(app.session.history.undo.len(), undo + gesture, "each drag is one undo step: {panel:?}");
|
|
}
|
|
app.session.execute("edit.undo", json!({})).unwrap();
|
|
app.session.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(app.session.active_comp().unwrap().layer(LayerId(lid)).unwrap().props.find(uid).unwrap().value, original);
|
|
}
|
|
}
|
|
}
|