From da67ea95533b3b905173a64f9042974b0b995271 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:38:57 +1100 Subject: [PATCH 01/18] Keep a composition work area inside the composition and at least one frame long. Setting the start past the current end, or shortening the composition, stored an inverted interval. Trimming that interval then saved a negative duration. --- crates/engine/src/commands/comp.rs | 25 +++++++++++ crates/engine/src/tests_menu_cmds.rs | 64 ++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a3267fa16..877993e4a 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -76,6 +76,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { c.work_area.1 = c.duration; } c.work_area.1 = c.work_area.1.min(c.duration); + // A shorter composition must not leave the start past the end. + keep_work_area_ordered(c); } if let Some(pa) = f_p(p, "pixelAspect") { c.pixel_aspect = pa.max(0.1); @@ -196,17 +198,40 @@ fn work_area(s: &mut Session, p: &Value) -> Result { if let Some(e) = end { c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration); } + // A start on its own can land after the current end. Pull it back so the interval + // still covers one frame, the same rule as `set: "begin"`. + keep_work_area_ordered(c); Ok(()) })?; let c = s.project.comp(cid).ok_or(EngineError::NoComp)?; Ok(json!({"start": c.work_area.0.seconds(), "end": c.work_area.1.seconds()})) } +/// Keep `work_area` inside the composition and at least one frame long. +fn keep_work_area_ordered(c: &mut Comp) { + let fd = c.frame_duration(); + c.work_area.1 = c.work_area.1.min(c.duration); + if c.work_area.0 < Tick::ZERO { + c.work_area.0 = Tick::ZERO; + } + if c.work_area.0 + fd > c.work_area.1 { + c.work_area.0 = (c.work_area.1 - fd).max(Tick::ZERO); + } + if c.work_area.0 + fd > c.work_area.1 { + c.work_area.1 = (c.work_area.0 + fd).min(c.duration); + } +} + fn trim_to_wa(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; s.edit("Trim Comp to Work Area", None, |proj, _| { let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; let (a, b) = c.work_area; + let fd = c.frame_duration(); + // An interval that is already inverted must not become a negative duration. + if a < Tick::ZERO || b > c.duration || a + fd > b { + return Err(bad("comp.trimToWorkArea", "the work area must cover at least one frame inside the composition")); + } for l in &mut c.layers { l.start_time -= a; l.in_point = (l.in_point - a).max(Tick::ZERO); diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72ff..b8c534b80 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -253,6 +254,69 @@ fn lift_and_extract_work_area() { assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}"); } +#[test] +fn work_area_stays_ordered_when_the_start_passes_the_end() { + let ordered = |s: &Session| { + let c = s.active_comp().unwrap(); + let fd = c.frame_duration(); + assert!(c.duration >= fd, "duration {}", c.duration.seconds()); + assert!(c.work_area.0 >= Tick::ZERO); + assert!(c.work_area.0 + fd <= c.work_area.1, "start {} end {}", c.work_area.0.seconds(), c.work_area.1.seconds()); + assert!(c.work_area.1 <= c.duration); + (c.duration, c.work_area) + }; + let one_frame_at_two = |s: &Session| { + let end = Tick::from_seconds_f64(2.0); + (end - s.active_comp().unwrap().frame_duration(), end) + }; + + let mut s = comp(); + s.execute("comp.workArea", json!({"end": 2.0})).unwrap(); + s.execute("comp.workArea", json!({"start": 8.0})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s).1, (Tick::ZERO, Tick::from_seconds_f64(2.0))); + + s.execute("comp.workArea", json!({"start": 0.0, "end": 2.0})).unwrap(); + s.execute("comp.trimToWorkArea", json!({})).unwrap(); + assert_eq!(ordered(&s), (Tick::from_seconds_f64(2.0), (Tick::ZERO, Tick::from_seconds_f64(2.0)))); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s).0, Tick::from_seconds_f64(10.0)); + + let mut s = comp(); + s.execute("comp.workArea", json!({"start": 9.0, "end": 10.0})).unwrap(); + let before = ordered(&s); + s.execute("comp.settings", json!({"duration": 2.0})).unwrap(); + let (dur, wa) = ordered(&s); + assert_eq!(dur, Tick::from_seconds_f64(2.0)); + assert_eq!(wa, one_frame_at_two(&s)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(ordered(&s), before); + + let mut s = comp(); + s.execute("comp.workArea", json!({"end": 2.0})).unwrap(); + s.execute("time.set", json!({"time": 8.0})).unwrap(); + s.execute("comp.workArea", json!({"set": "begin"})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + let path = tmp("WorkArea.ecproj"); + s.execute("file.saveAs", json!({"path": path})).unwrap(); + s.execute("file.open", json!({"path": path})).unwrap(); + assert_eq!(ordered(&s).1, one_frame_at_two(&s)); + + // A project that already has an inverted interval must not trim into a negative duration. + let mut s = comp(); + let cid = s.state.active_comp.unwrap(); + { + let c = std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap(); + c.work_area = (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0)); + } + let err = s.execute("comp.trimToWorkArea", json!({})).unwrap_err(); + assert!(err.to_string().contains("work area"), "{err}"); + let c = s.active_comp().unwrap(); + assert_eq!(c.duration, Tick::from_seconds_f64(10.0)); + assert_eq!(c.work_area, (Tick::from_seconds_f64(8.0), Tick::from_seconds_f64(2.0))); +} + #[test] fn label_group_purge_and_edit_original() { crate::tests_roto::hold_roto_cache_test_lock(); From 39a0c6aa547d6bad87bc22cc0824f45ee54ce0b1 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:42:03 +1100 Subject: [PATCH 02/18] Move a layer when Position is edited and its dimensions are separated. The renderer follows X, Y and Z Position once those channels exist. The Position command was writing only the combined property, so the layer stayed where it was. --- crates/engine/src/commands/layer_menu.rs | 26 ++++++++++++ crates/engine/src/tests_menu_cmds.rs | 53 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 5ad0c1fcf..ba00db38f 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -192,6 +192,32 @@ fn set_transform(s: &mut Session, p: &Value) -> Result { for lid in &ids { let l = layer_mut(proj, cid, *lid)?; let lt = l.layer_time(t); + // Separate Dimensions: the picture follows X/Y/Z Position, not the combined property. + if m == "position" && l.transform().is_some_and(|tr| tr.get("positionX").is_some()) { + let cur = l + .transform() + .map(|tr| { + [ + tr.get("positionX").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + tr.get("positionY").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + tr.get("positionZ").map(|p| p.value_at(lt).as_f64()).unwrap_or(0.0), + ] + }) + .unwrap_or([0.0; 3]); + // Two numbers keep the current z. + let j = match &v { + Value::Array(a) if a.len() == 2 => json!([a[0], a[1], cur[2]]), + _ => v.clone(), + }; + let nv = KV::Vec3(cur).coerce_json(&j).ok_or_else(|| bad("layer.setTransform", format!("can't use {v} for {label}")))?; + let xyz = nv.as_vec3(); + let Some(tr) = l.transform_mut() else { continue }; + super::layer::update_position(tr, lt, |_| xyz); + if let Some(pr) = tr.get_mut("position") { + pr.set_value_at(lt, KV::Vec3(xyz)); + } + continue; + } let Some(pr) = l.transform_mut().and_then(|tr| tr.get_mut(m)) else { continue }; let cur = pr.value_at(lt); // Two numbers for a 3D value keep the current z. diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72ff..0d626dd81 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -103,6 +104,58 @@ fn transform_dialog_values_and_center_anchor() { assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath); } +/// Pixel-centre of the opaque samples, so a 10×10 solid centred on (11, 22) reports (11, 22). +fn opaque_centroid(s: &Session) -> (f64, f64, u32) { + let img = s.render(s.active_comp_id().unwrap(), Tick::ZERO, Default::default()); + let mut n = 0u32; + let mut sx = 0.0; + let mut sy = 0.0; + for y in 0..img.height { + for x in 0..img.width { + if img.get(x as i64, y as i64)[3] > 0.5 { + n += 1; + sx += x as f64 + 0.5; + sy += y as f64 + 0.5; + } + } + } + assert!(n > 0, "the layer rendered nothing"); + (sx / n as f64, sy / n as f64, n) +} + +#[test] +fn separated_position_set_transform_moves_the_layer() { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Scene", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ff0000", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "starts centred, centroid ({x}, {y})"); + + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + s.execute("layer.setTransform", json!({"layers": [a], "prop": "position", "value": [11, 22]})).unwrap(); + let l = layer(&s, a); + let tr = l.transform().unwrap(); + assert!((tr.get("positionX").unwrap().value.as_f64() - 11.0).abs() < 1e-6); + assert!((tr.get("positionY").unwrap().value.as_f64() - 22.0).abs() < 1e-6); + assert_eq!(tr.get("position").unwrap().value, KV::Vec3([11.0, 22.0, 0.0])); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "separated position renders at ({x}, {y})"); + + s.execute("edit.undo", json!({})).unwrap(); + let (x, y, _) = opaque_centroid(&s); + assert!((x - 50.0).abs() < 0.6 && (y - 50.0).abs() < 0.6, "undo puts it back at ({x}, {y})"); + + // Combined Position, the control, still moves. + s.execute("edit.undo", json!({})).unwrap(); + assert!(layer(&s, a).transform().unwrap().get("positionX").is_none()); + s.execute("layer.setTransform", json!({"prop": "position", "value": [11, 22]})).unwrap(); + let (x, y, n) = opaque_centroid(&s); + assert_eq!(n, 100); + assert!((x - 11.0).abs() < 0.6 && (y - 22.0).abs() < 0.6, "combined position renders at ({x}, {y})"); +} + #[test] fn mask_menu_on_existing_masks() { let mut s = comp(); From be9e1e9f425b7a2a3ed0e669cdf8b70c88d02770 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:45:20 +1100 Subject: [PATCH 03/18] Show a layer through its first Intersect or Darken mask. Those modes were combined with an empty coverage, so the first one hid an opaque solid. A later mask still intersects or darkens. --- crates/render/src/masks.rs | 7 +++++++ crates/render/src/tests.rs | 39 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/render/src/masks.rs b/crates/render/src/masks.rs index 980e64751..969e8d641 100644 --- a/crates/render/src/masks.rs +++ b/crates/render/src/masks.rs @@ -145,9 +145,16 @@ pub fn apply(ctx: &EvalCtx, layer: &Layer, buf: &mut Buf, blur: MaskBlur) -> boo let n = (buf.img.width * buf.img.height) as usize; let first_sub = matches!(list[0].kind, GroupKind::Mask { mode: MaskMode::Subtract, .. }); let mut acc = vec![if first_sub { 1.0f32 } else { 0.0 }; n]; + // The first mask has nothing to combine with. Intersect and Darken would multiply or + // minimise against that empty coverage and hide the layer; they cover like Add instead. + // Lighten and Difference already do. Filling the accumulator first would make Add show + // the whole layer. + let mut first = true; for g in list { let GroupKind::Mask { mode, inverted, .. } = g.kind else { continue }; let Some(mut cov) = coverage(ctx, layer, g, buf, blur) else { continue }; + let mode = if first && matches!(mode, MaskMode::Intersect | MaskMode::Darken) { MaskMode::Add } else { mode }; + first = false; let op = (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32; cov.data.par_iter_mut().for_each(|c| { if inverted { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 93c9a1a5b..56d84b8dc 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -80,6 +80,45 @@ fn mask_cuts_layer() { assert!(img.get(150, 50)[3] < 0.01); } +#[test] +fn first_intersect_and_darken_keep_their_rectangle() { + let render = |modes: &[MaskMode], centers: &[[f64; 2]]| { + let (mut p, cid, comp) = setup(); + let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], 200, 100); + for (i, (mode, center)) in modes.iter().zip(centers).enumerate() { + let mut next = p.next_id; + let m = build::mask(&mut Ids(&mut next), &format!("Mask {}", i + 1), ShapePath::rect(*center, 40.0, 40.0), *mode, [255, 255, 0]); + p.next_id = next; + l.props.sub_mut("masks").unwrap().children.push(m.into()); + } + p.comp_mut(cid).unwrap().layers.push(l); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + // A first Intersect or Darken has nothing to combine with, so it covers like Add. + // Lighten and Difference already do; Subtract still starts from the whole layer. + for mode in [MaskMode::Intersect, MaskMode::Darken, MaskMode::Add, MaskMode::Lighten, MaskMode::Difference] { + let img = render(&[mode], &[[50.0, 50.0]]); + assert!(img.get(50, 50)[3] > 0.99, "{mode:?} keeps the rectangle"); + assert!(img.get(150, 50)[3] < 0.01, "{mode:?} stays outside it"); + } + let hole = render(&[MaskMode::Subtract], &[[50.0, 50.0]]); + assert!(hole.get(50, 50)[3] < 0.01, "subtract still cuts its rectangle"); + assert!(hole.get(150, 50)[3] > 0.99, "subtract leaves the rest of the layer"); + + // Add, then Intersect with a shifted rectangle: only the overlap remains. + let overlap = render(&[MaskMode::Add, MaskMode::Intersect], &[[50.0, 50.0], [70.0, 50.0]]); + assert!(overlap.get(50, 50)[3] > 0.99, "overlap stays"); + assert!(overlap.get(35, 50)[3] < 0.01, "the first rectangle's exclusive side is cut"); + assert!(overlap.get(85, 50)[3] < 0.01, "the second rectangle's exclusive side is cut"); + let darkened = render(&[MaskMode::Add, MaskMode::Darken], &[[50.0, 50.0], [70.0, 50.0]]); + assert!(darkened.get(50, 50)[3] > 0.99 && darkened.get(35, 50)[3] < 0.01 && darkened.get(85, 50)[3] < 0.01); + + // A later disjoint Add still contributes after a first Intersect. + let both = render(&[MaskMode::Intersect, MaskMode::Add], &[[50.0, 50.0], [150.0, 50.0]]); + assert!(both.get(50, 50)[3] > 0.99 && both.get(150, 50)[3] > 0.99); + assert!(both.get(100, 50)[3] < 0.01); +} + #[test] fn track_matte_alpha() { let (mut p, cid, comp) = setup(); From 9f53d9c6bc19610be049a659c4850c53817da38b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 21:47:15 +1100 Subject: [PATCH 04/18] Move separated Position when the composition frame is resized or cropped. Those edits offset the combined Position property, which a separated layer does not render. X and Y Position, including their keys, now move by the same amount. --- crates/engine/src/commands/comp.rs | 15 +--- crates/engine/src/commands/comp_more.rs | 11 +-- crates/engine/src/commands/layer.rs | 31 ++++++++ crates/engine/src/tests_menu_cmds.rs | 95 +++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index a3267fa16..769ed3627 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -36,19 +36,8 @@ fn apply_settings(c: &mut Comp, p: &Value) { let dy = (c.height as f64 - oh as f64) * (a / 3) as f64 / 2.0; if dx != 0.0 || dy != 0.0 { for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pr) = l.props.prop_mut("transform/position") else { continue }; - let shift = |v: &mut effectcraft_keyframe::Value| { - if let effectcraft_keyframe::Value::Vec2(x) = v { - x[0] += dx; - x[1] += dy; - } else if let effectcraft_keyframe::Value::Vec3(x) = v { - x[0] += dx; - x[1] += dy; - } - }; - shift(&mut pr.value); - for k in &mut pr.keys { - shift(&mut k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, dx, dy); } } } diff --git a/crates/engine/src/commands/comp_more.rs b/crates/engine/src/commands/comp_more.rs index 0f07fd67c..2c4ca409b 100644 --- a/crates/engine/src/commands/comp_more.rs +++ b/crates/engine/src/commands/comp_more.rs @@ -1,7 +1,6 @@ //! Composition menu, part two: crop to region of interest / layer bounds, Save Frame As ▸ File, //! Responsive Design — Time. -use effectcraft_keyframe::Value as KV; use effectcraft_project::Marker; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -24,14 +23,8 @@ fn crop(s: &mut Session, cid: effectcraft_project::ItemId, r: [f64; 4], label: & c.width = w; c.height = h; for l in c.layers.iter_mut().filter(|l| l.parent.is_none()) { - let Some(pos) = l.transform_mut().and_then(|tr| tr.get_mut("position")) else { continue }; - let mv = |v: &KV| { - let a = v.as_vec3(); - KV::Vec3([a[0] - x, a[1] - y, a[2]]) - }; - pos.value = mv(&pos.value); - for k in &mut pos.keys { - k.value = mv(&k.value); + if let Some(tr) = l.transform_mut() { + super::layer::offset_position(tr, -x, -y); } } for g in &mut c.guides { diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index eaf70cf75..0c8472c8a 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -1124,6 +1124,37 @@ fn set_text(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well +/// as the combined property the channels were split from. +pub(crate) fn offset_position(tr: &mut PropGroup, dx: f64, dy: f64) { + if dx == 0.0 && dy == 0.0 { + return; + } + if tr.get("positionX").is_some() { + for (name, d) in [("positionX", dx), ("positionY", dy)] { + if let Some(pr) = tr.get_mut(name) { + pr.value = KV::Scalar(pr.value.as_f64() + d); + for k in &mut pr.keys { + k.value = KV::Scalar(k.value.as_f64() + d); + } + } + } + } + if let Some(pr) = tr.get_mut("position") { + let shift = |v: &KV| match v { + KV::Vec2(a) => KV::Vec2([a[0] + dx, a[1] + dy]), + other => { + let a = other.as_vec3(); + KV::Vec3([a[0] + dx, a[1] + dy, a[2]]) + } + }; + pr.value = shift(&pr.value); + for k in &mut pr.keys { + k.value = shift(&k.value); + } + } +} + /// Set a layer's Position at layer time `lt` to `f(current)`: X/Y/Z Position when its dimensions /// are separated. pub(crate) fn update_position(tr: &mut PropGroup, lt: Tick, f: impl Fn([f64; 3]) -> [f64; 3]) { diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c342f72ff..01886936a 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -3,6 +3,7 @@ use effectcraft_keyframe::Value as KV; use effectcraft_project::{FrameBlend, ItemKind, MatteKind, Quality, Sampling}; +use effectcraft_time::Tick; use serde_json::{Value, json}; use crate::{EngineError, Event, Session}; @@ -103,6 +104,100 @@ fn transform_dialog_values_and_center_anchor() { assert_eq!(layer(&s, a).auto_orient, effectcraft_project::AutoOrient::AlongPath); } +/// Inclusive bounds of samples whose alpha is above one half, plus the frame size and the count. +fn opaque_bounds(s: &Session, t: Tick) -> (u32, u32, i64, i64, i64, i64, u32) { + let img = s.render(s.active_comp_id().unwrap(), t, Default::default()); + let mut n = 0u32; + let mut x0 = i64::MAX; + let mut y0 = i64::MAX; + let mut x1 = i64::MIN; + let mut y1 = i64::MIN; + for y in 0..img.height as i64 { + for x in 0..img.width as i64 { + if img.get(x, y)[3] > 0.5 { + n += 1; + x0 = x0.min(x); + y0 = y0.min(y); + x1 = x1.max(x); + y1 = y1.max(y); + } + } + } + (img.width, img.height, x0, y0, x1, y1, n) +} + +#[test] +fn separated_position_follows_canvas_resize_and_crop() { + let square = |separated: bool| { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Test", "width": 100, "height": 100, "frameRate": 10, "duration": 2})).unwrap(); + let a = s.execute("layer.newSolid", json!({"name": "Square", "color": "#ffffff", "width": 10, "height": 10})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.setTransform", json!({"layer": a, "prop": "position", "value": [40, 50]})).unwrap(); + if separated { + s.execute("prop.separateDimensions", json!({"layer": a, "value": true})).unwrap(); + } + (s, a) + }; + let placed = |s: &Session, a: u64, t: f64, x: f64, y: f64| { + let l = layer(s, a); + let tr = l.transform().unwrap(); + let at = Tick::from_seconds_f64(t); + if tr.get("positionX").is_some() { + assert!((tr.get("positionX").unwrap().value_at(at).as_f64() - x).abs() < 1e-6); + assert!((tr.get("positionY").unwrap().value_at(at).as_f64() - y).abs() < 1e-6); + } + let p = tr.get("position").unwrap().value_at(at).as_vec3(); + assert!((p[0] - x).abs() < 1e-6 && (p[1] - y).abs() < 1e-6, "combined {p:?}"); + }; + + let (mut combined, a) = square(false); + let (mut separated, b) = square(true); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + combined.execute("comp.settings", json!({"width": 200})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&combined, a, 0.0, 90.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (200, 100, 85, 45, 94, 54, 100)); + assert_eq!(opaque_bounds(&combined, Tick::ZERO), opaque_bounds(&separated, Tick::ZERO)); + + let (mut separated, b) = square(true); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + + let (mut separated, b) = square(true); + separated.execute("layer.select", json!({"layers": [b]})).unwrap(); + separated.execute("comp.cropToLayerBounds", json!({})).unwrap(); + let bounds = opaque_bounds(&separated, Tick::ZERO); + assert_eq!((bounds.0, bounds.1, bounds.6), (10, 10, 100)); + assert!(bounds.2 == 0 && bounds.3 == 0, "the square fills the cropped frame: {bounds:?}"); + + // Two Position keys keep their spacing after the same canvas offset. + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("comp.settings", json!({"width": 200})).unwrap(); + placed(&separated, b, 0.0, 90.0, 50.0); + placed(&separated, b, 1.0, 110.0, 50.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO).2, 85); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)).2, 105); + + let (mut separated, b) = square(true); + separated.execute("edit.undo", json!({})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 0, "value": [40, 50]})).unwrap(); + separated.execute("prop.addKey", json!({"layer": b, "path": "transform/position", "time": 1, "value": [60, 50]})).unwrap(); + separated.execute("prop.separateDimensions", json!({"layer": b, "value": true})).unwrap(); + separated.execute("view.setRegionOfInterest", json!({"rect": [10, 20, 70, 70]})).unwrap(); + separated.execute("comp.cropToRegionOfInterest", json!({})).unwrap(); + placed(&separated, b, 0.0, 30.0, 30.0); + placed(&separated, b, 1.0, 50.0, 30.0); + assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); + assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100)); +} + #[test] fn mask_menu_on_existing_masks() { let mut s = comp(); From 4e8a906fae424bd81bfdca86dd68e2d17f89b4bb Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:52:49 +0200 Subject: [PATCH 05/18] Rotation: dragging the degrees field can go negative The degrees field shows the remainder's size, so a drag was applied to the size and the sign was re-applied afterwards; past zero it flipped back to positive. A drag now moves the angle itself. Fixes #569 Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/fx_widgets.rs | 32 +++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index 036ae0701..ca49c3fd0 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -41,6 +41,17 @@ pub fn parse_angle(s: &str) -> Option { s.parse().ok() } +/// The angle after the degrees field of `v` (which shows `shown`, the remainder's size) went to +/// `new`. A drag moves the angle by the pointer's distance, so it runs through zero into negative +/// angles; a typed number is a size, so the remainder keeps its sign. +fn degrees_edit(v: f64, shown: f64, new: f64, dragging: bool) -> f64 { + if dragging { + return v + (new - shown); + } + let (rev, deg) = split_angle(v); + rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -new } else { new } +} + /// An angle value, `0x+45.0°`, at `at` (top left): the revolutions and the degrees /// are hot numbers of their own (drag to scrub, click to type) and changing one keeps the other. /// Returns the revolutions' rect, the degrees' rect and the new angle when either changed. @@ -49,10 +60,10 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t); let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text); let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t); + let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id); let out = match (new_rev, new_deg) { (Some(r), _) => Some(r as f64 * 360.0 + deg), - // The degrees field shows the remainder's size; its sign stays. - (None, Some(d)) => Some(rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -d } else { d }), + (None, Some(d)) => Some(degrees_edit(v, deg.abs(), d, dragging)), (None, None) => None, }; (rr, dr, out) @@ -551,6 +562,23 @@ mod tests { assert_eq!(split_angle(-360.0), (-1, 0.0)); } + #[test] + fn dragging_degrees_crosses_zero_into_negative_angles() { + // The field shows the size (0.3), so a 1-degree drag left lands on -0.7, and keeps going. + let mut v = 0.3; + for _ in 0..3 { + let (_, deg) = split_angle(v); + v = degrees_edit(v, deg.abs(), deg.abs() - 1.0, true); + } + assert!((v + 2.7).abs() < 1e-9, "{v}"); + // And back up through zero. + let (_, deg) = split_angle(v); + assert!((degrees_edit(v, deg.abs(), deg.abs() + 3.0, true) - 0.3).abs() < 1e-9); + // Typing keeps the sign shown. + assert_eq!(degrees_edit(-30.0, 30.0, 45.0, false), -45.0); + assert_eq!(degrees_edit(405.0, 45.0, 10.0, false), 370.0); + } + #[test] fn angle_parsing_round_trips() { for v in [0.0, 45.0, 405.0, -30.5, -400.0, 1080.25] { From 00156ce22ed7d415790c3b67d7439f4967f32bde Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:57:45 +0200 Subject: [PATCH 06/18] Effect reset: restore parameters inside twirl-down groups effect.reset looked parameters up by their full spec id among the effect's direct children, so every 'group/param' id (most of Fractal, Scribble, and other grouped effects) was skipped and Reset did nothing for them. Fixes #526 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/effect.rs | 3 ++- crates/engine/src/tests_effects.rs | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/commands/effect.rs b/crates/engine/src/commands/effect.rs index 686846504..fe0d42100 100644 --- a/crates/engine/src/commands/effect.rs +++ b/crates/engine/src/commands/effect.rs @@ -387,7 +387,8 @@ fn reset(s: &mut Session, p: &Value) -> Result { s.edit(&format!("Reset {name}"), None, |proj, _| { let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("effect.reset", "gone"))?; for ps in &spec.params { - if let Some(pr) = g.get_mut(ps.id) { + // (`group/param` ids live in twirl-down groups.) + if let Some(pr) = g.prop_mut(ps.id) { pr.set_value_at(lt, effectcraft_effects::default_value(ps, size)); } } diff --git a/crates/engine/src/tests_effects.rs b/crates/engine/src/tests_effects.rs index 2f1649916..82f510366 100644 --- a/crates/engine/src/tests_effects.rs +++ b/crates/engine/src/tests_effects.rs @@ -110,6 +110,29 @@ fn reset_effect_restores_defaults_in_one_undo_step() { assert_eq!(g.get("start").unwrap().value, KV::Vec2([10.0, 20.0])); } +#[test] +fn reset_restores_parameters_inside_twirl_down_groups() { + let (mut s, a, _) = comp_with_two_solids(); + // Fractal and Scribble keep most parameters in groups (`mandelbrot/…`, `edgeOptions/…`). + for (effect, changes) in [ + ("Fractal", [("setChoice", json!(1)), ("mandelbrot/mandelbrotX", json!(1.25)), ("fractalColor/hue", json!(90.0))]), + ("Scribble", [("scribble", json!(2)), ("edgeOptions/edgeWidth", json!(33.0)), ("strokeOptions/spacing", json!(9.0))]), + ] { + let fx = apply(&mut s, a, effect); + let group = |s: &Session| layer(s, a).effects().unwrap().groups().find(|g| g.uid == fx).unwrap().clone(); + let defaults: Vec<_> = changes.iter().map(|(id, _)| group(&s).prop(id).unwrap().value.clone()).collect(); + for (id, v) in &changes { + let uid = group(&s).prop(id).unwrap().uid; + s.execute("prop.set", json!({"layer": a, "prop": uid, "value": v})).unwrap(); + assert_ne!(group(&s).prop(id).unwrap().value, defaults[changes.iter().position(|(i, _)| i == id).unwrap()], "{effect} {id} changed"); + } + s.execute("effect.reset", json!({"layer": a, "effect": fx})).unwrap(); + for ((id, _), d) in changes.iter().zip(&defaults) { + assert_eq!(&group(&s).prop(id).unwrap().value, d, "{effect} {id} back to its default"); + } + } +} + #[test] fn reset_keys_animated_params_at_the_cti() { let (mut s, a, _) = comp_with_two_solids(); From 98cdc981ad674fe34a417a20c8893844701ad638 Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:21:21 +0530 Subject: [PATCH 07/18] Footage: a file that can't be read is tried again once it changes, not on every look A footage file the automatic reload couldn't read (still being written, or one the importer doesn't read) kept its old stamp, so every two-second look saw it as changed and probed it again. It now keeps its new stamp and is tried again once it changes again (when the other app finishes writing it). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/footage_reload.rs | 9 ++++----- docs/preferences.md | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index 0d26a45c8..3a7f0719b 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -184,8 +184,8 @@ fn forget(s: &mut Session, paths: &BTreeSet) { /// time is only remembered; one whose stamp moved is reloaded: the items reading it get their /// size and duration from it again and its pixels are read again. A file that can't be stamped /// right now (an app saving by delete and rename) keeps its last stamp, and one that can't be -/// read yet (still being written) keeps its old stamp so the next scan tries again; missing -/// files are left to the footage check. Returns `{files, reloaded}`. +/// read (still being written) is tried again once it changes again; missing files are left to +/// the footage check. Returns `{files, reloaded}`. pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate::Result { let before = std::mem::take(&mut s.footage_stamps); let mut changed = BTreeSet::new(); @@ -209,10 +209,9 @@ pub fn apply_stamps(s: &mut Session, now: Vec<(String, Option)>) -> crate } let ids: Vec = s.project.items.values().filter(|i| matches!(&i.kind, ItemKind::Footage(f) if changed.contains(&f.path))).map(|i| i.id).collect(); let (reloaded, failed) = if ids.is_empty() { (0, BTreeSet::new()) } else { reload(s, &ids, false)? }; + // Not readable (still being written, or not a file the importer reads): its new stamp is kept, + // so it is tried again once it changes again, not on every look. for p in &failed { - if let Some(old) = before.get(p) { - s.footage_stamps.insert(p.clone(), *old); - } changed.remove(p); } if changed.is_empty() { diff --git a/docs/preferences.md b/docs/preferences.md index 75c606b7a..f6909cefe 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -114,7 +114,7 @@ Commands (CLI, MCP, control channel): size and modification time with what they were, and reloads what another app saved: a Photoshop file edited in PhotoCraft or Photoshop shows its new pixels without File ▸ Reload Footage. It is not an undo step and doesn't modify the project; a layer item stays on its layer - (found again by name), and a file still being written is tried again on the next check. + (found again by name), and a file that can't be read yet (still being written) is tried again once it changes again. `footage.reloadChanged` runs the same check once (CLI, agents) - `import.unlabeledAlpha`: alpha of TGA / TIFF / movie footage (Ask opens Interpret Footage; Guess takes premultiplied for movies, straight for stills) From d86c4aff520feaec3ae5cce12f99229e941cc0e8 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:36:59 -0400 Subject: [PATCH 08/18] Startup: a launch that stops as its window is shown switches the next to OpenGL On an ASUS G513QY (Radeon RX 6800M, Adrenalin 26.9.2, Windows 11) every launch died with STATUS_ACCESS_VIOLATION inside amdvlk64.dll, on a driver worker thread, a moment after the first frame: eframe shows the window it created hidden, the resulting resize re-creates the swapchain, and the driver crashes there (same offset on two driver versions, with the OBS/Steam/Epic Vulkan layers disabled, and with FIFO, immediate and latency-1 presentation). DirectX 12 and OpenGL open the window fine. A crash in a driver can't be caught, and the launch guard (#243) removed its marker at the first frame, so this looked like a successful launch every time and Window Graphics never switched. The marker now stays until the window has kept drawing for SETTLE (3 s) or the app quits normally before that; frame_drawn returns the time left and the app asks for a repaint then, so an idle window settles too. The notice, the Settings note and docs/preferences.md describe the new rule. Verified on that machine with a scratch settings folder: launch 1 (Vulkan) crashed and left the marker, launch 2 switched to OpenGL with the notice and closed with exit code 0, launch 3 stayed on OpenGL without the notice. Tests: effectcraft launch_guard a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl (a first frame then drop: the next launch gets GL and the notice; a normal quit in that time leaves no marker), and the existing test now asserts that the first frames don't settle a launch (marker present at SETTLE/2, gone at SETTLE). Co-Authored-By: Claude Fable 5.1 --- apps/effectcraft/src/launch_guard.rs | 111 ++++++++++++++++++++++----- apps/effectcraft/src/main.rs | 11 ++- crates/engine/src/prefs.rs | 6 +- docs/preferences.md | 2 +- 4 files changed, 102 insertions(+), 28 deletions(-) diff --git a/apps/effectcraft/src/launch_guard.rs b/apps/effectcraft/src/launch_guard.rs index 14c4c440e..fac91c05b 100644 --- a/apps/effectcraft/src/launch_guard.rs +++ b/apps/effectcraft/src/launch_guard.rs @@ -1,39 +1,52 @@ //! Settings ▸ Startup & Repair ▸ Window Graphics, and the launch guard behind it. //! //! Some graphics drivers crash EffectCraft as its window's device is created (an access -//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243). A crash in a driver -//! can't be caught, so each launch leaves a marker in the settings folder until its window has -//! drawn a frame. A launch that finds the previous one's marker switches Window Graphics to -//! OpenGL, which those drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's -//! own override) still wins over both. The marker is locked while its launch runs, so a second -//! copy opened meanwhile isn't taken for a crash; and a window OpenGL can't open either puts -//! Window Graphics back to Automatic ([`opengl_failed`]) rather than failing every launch. +//! violation in an older Intel UHD driver with DirectX 12 / Vulkan, #243), or a moment after +//! its first frame, as the window is shown and its surface re-created (AMD's Vulkan driver for +//! a Radeon RX 6800M, Adrenalin 26.9.2: an access violation in amdvlk64.dll on every launch). +//! A crash in a driver can't be caught, so each launch leaves a marker in the settings folder +//! until its window has kept drawing for [`SETTLE`] (or the app quits normally before then). A +//! launch that finds the previous one's marker switches Window Graphics to OpenGL, which those +//! drivers run, and says so once the window is up. `WGPU_BACKEND` (wgpu's own override) still +//! wins over both. The marker is locked while its launch runs, so a second copy opened meanwhile +//! isn't taken for a crash; and a window OpenGL can't open either puts Window Graphics back to +//! Automatic ([`opengl_failed`]) rather than failing every launch. use std::fs::File; use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; use effectcraft_engine::prefs::{PREFS_FILE, Prefs}; use eframe::wgpu::Backends; -/// Left in the settings folder from a launch until its window draws. +/// Left in the settings folder from a launch until its window has settled. const MARKER: &str = "launch-pending"; +/// How long the window keeps drawing after its first frame before the launch counts as made. +/// A driver that fails as the window is shown and its surface re-created does so within the +/// first second; a user quitting sooner than this is told the next launch uses OpenGL and can +/// switch back in Settings. +pub const SETTLE: Duration = Duration::from_secs(3); + /// This launch's window graphics. #[derive(Debug, Default)] pub struct Launch { /// The graphics APIs the window may use (`None`: eframe's default). pub backends: Option, - /// Switched to OpenGL because the previous launch never drew: the message to show. + /// Switched to OpenGL because the previous launch stopped as its window opened: the message + /// to show. pub notice: Option, marker: Option, - /// Holds the marker's lock until the window draws (or the process ends). + /// Holds the marker's lock until the launch settles (or the process ends). lock: Option, + /// When the window drew its first frame, until the launch settles. + first_frame: Option, } impl Launch { /// Read Window Graphics in the settings folder `dir`, switch it to OpenGL when the previous - /// launch never drew (where OpenGL exists: not macOS), and leave this launch's marker. - /// `env_override`: `WGPU_BACKEND` is set. + /// launch stopped as its window opened (where OpenGL exists: not macOS), and leave this + /// launch's marker. `env_override`: `WGPU_BACKEND` is set. pub fn begin(dir: Option<&Path>, env_override: bool) -> Launch { let Some(dir) = dir.filter(|_| !env_override) else { return Launch::default() }; let prefs_path = dir.join(PREFS_FILE); @@ -45,11 +58,11 @@ impl Launch { if marker.exists() && !held && prefs.startup.window_graphics == "auto" && cfg!(not(target_os = "macos")) { prefs.startup.window_graphics = "gl".into(); match std::fs::create_dir_all(dir).and_then(|()| std::fs::write(&prefs_path, prefs.to_json())) { - Ok(()) => log::warn!("the last launch never drew its window: switching Window Graphics to OpenGL"), - Err(e) => log::warn!("the last launch never drew its window; saving Window Graphics failed: {e}"), + Ok(()) => log::warn!("the last launch stopped as its window opened: switching Window Graphics to OpenGL"), + Err(e) => log::warn!("the last launch stopped as its window opened; saving Window Graphics failed: {e}"), } notice = Some( - "EffectCraft's last launch stopped before its window opened, so it now draws with OpenGL \ + "EffectCraft's last launch stopped as its window opened, so it now draws with OpenGL \ (Settings ▸ Startup & Repair ▸ Window Graphics). Updating the graphics driver may let it use \ Automatic again." .to_string(), @@ -64,11 +77,33 @@ impl Launch { (None, None) } }; - Launch { backends, notice, marker, lock } + Launch { backends, notice, marker, lock, first_frame: None } } - /// The window drew its first frame: the launch made it. - pub fn drawn(&mut self) { + /// The window drew a frame. Until the launch has settled, returns how long that still takes + /// (ask for a repaint then, so that an idle window settles too). + pub fn frame_drawn(&mut self) -> Option { + self.frame_drawn_at(Instant::now()) + } + + fn frame_drawn_at(&mut self, now: Instant) -> Option { + self.marker.as_ref()?; + let first = *self.first_frame.get_or_insert(now); + let left = SETTLE.saturating_sub(now.saturating_duration_since(first)); + if left.is_zero() { + self.settled(); + return None; + } + Some(left) + } + + /// The app quits normally: however soon after opening, that's no crash. + pub fn exited(&mut self) { + self.settled(); + } + + /// The launch made it: release and remove its marker. + fn settled(&mut self) { self.lock = None; if let Some(m) = self.marker.take() && let Err(e) = std::fs::remove_file(&m) @@ -103,12 +138,18 @@ mod tests { fn a_launch_that_never_drew_switches_the_next_to_opengl() { let dir = std::env::temp_dir().join(format!("ec-launch-guard-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - // A first launch that draws: nothing changes. + // A first launch that draws: nothing changes. Its first frames don't settle it (a driver + // may still crash as the window is shown); frames for `SETTLE` do. let mut l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (None, false)); assert!(dir.join(MARKER).exists()); - l.drawn(); + let t0 = Instant::now(); + assert_eq!(l.frame_drawn_at(t0), Some(SETTLE)); + assert!(l.frame_drawn_at(t0 + SETTLE / 2).is_some_and(|left| left <= SETTLE / 2)); + assert!(dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None); assert!(!dir.join(MARKER).exists()); + assert_eq!(l.frame_drawn_at(t0 + SETTLE), None, "settled stays settled"); // A launch that crashes before drawing (it never calls `drawn`)… drop(Launch::begin(Some(&dir), false)); // …makes the next one use OpenGL and say so, once. @@ -119,7 +160,7 @@ mod tests { assert_eq!(l.backends, Some(Backends::GL)); assert!(l.notice.is_some()); assert_eq!(window_graphics(&dir), "gl", "saved in Settings"); - l.drawn(); + l.exited(); let l = Launch::begin(Some(&dir), false); assert_eq!((l.backends, l.notice.is_some()), (Some(Backends::GL), false), "it stays, without the notice"); } @@ -130,6 +171,34 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A launch that stops moments after its first frame (AMD's Vulkan driver crashing as the + /// window is shown and its surface re-created) switches the next one to OpenGL too; a + /// normal quit in that time doesn't. + #[test] + fn a_launch_that_stops_after_its_first_frame_switches_the_next_to_opengl() { + let dir = std::env::temp_dir().join(format!("ec-launch-guard-3-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + drop(l); + let l = Launch::begin(Some(&dir), false); + if cfg!(target_os = "macos") { + assert_eq!(l.backends, None, "no OpenGL on macOS"); + } else { + assert_eq!(l.backends, Some(Backends::GL)); + assert!(l.notice.is_some()); + } + drop(l); + let _ = std::fs::remove_dir_all(&dir); + // Quitting right after the window opened leaves no marker. + let mut l = Launch::begin(Some(&dir), false); + assert!(l.frame_drawn().is_some()); + l.exited(); + assert!(!dir.join(MARKER).exists()); + assert_eq!(Launch::begin(Some(&dir), false).backends, None); + let _ = std::fs::remove_dir_all(&dir); + } + /// A second copy opened while the first is still opening isn't a crash; OpenGL that can't /// open a window either goes back to Automatic instead of failing every launch. #[test] diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 72afab5ae..b4e398363 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -78,8 +78,8 @@ fn main() -> eframe::Result { // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. #[cfg(target_os = "linux")] appimage::integrate_from_env(ICON_PNG); - // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch's - // window never drew (a crashing graphics driver, #243). + // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch + // stopped as its window opened (a crashing graphics driver, #243). let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some()); let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL); let notice = launch.notice.take(); @@ -290,12 +290,17 @@ impl eframe::App for Desktop { self.app.ui(ui, frame); if !self.drawn { self.drawn = true; - self.launch.drawn(); log::info!("first frame drawn"); } + // The launch guard's marker goes once the window has kept drawing for a few seconds: a + // driver may still crash as the window is shown and its surface re-created. + if let Some(left) = self.launch.frame_drawn() { + ui.ctx().request_repaint_after(left); + } } fn on_exit(&mut self) { + self.launch.exited(); self.app.on_exit(); } diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 4ee6cc979..852c44c55 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -96,8 +96,8 @@ page!(Startup { /// After a crash, offer to open the most recent auto-save. offer_crash_recovery: bool = true, /// What the desktop window draws with, from the next launch: `auto` (the platform's best - /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch whose - /// window never drew switches it to `gl` (#243). + /// graphics API) or `gl` (OpenGL, for drivers that crash with the others). A launch that + /// stops as its window opens switches it to `gl` (#243). window_graphics: String = "auto".into(), }); @@ -948,7 +948,7 @@ pub fn pages() -> Vec { s("startup.offerCrashRecovery", "Offer to Open the Latest Auto-Save After a Crash", B, true), s("startup.windowGraphics", "Window Graphics", Kind::Choice(WINDOW_GRAPHICS), true), Note( - "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft before its window draws, the next launch switches to OpenGL.", + "Window Graphics applies from the next launch. When the graphics driver stops EffectCraft as its window opens, the next launch switches to OpenGL.", ), Section("Repair"), Button { label: "Reset Settings", command: "prefs.reset", params: "{}" }, diff --git a/docs/preferences.md b/docs/preferences.md index 9e3d4147d..426f5d25e 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -38,7 +38,7 @@ Commands (CLI, MCP, control channel): - `general.defaultSpatialLinear`: Default Spatial Interpolation to Linear - `startup.showHomeOnLaunch`: Show Home Screen When Launching - `startup.offerCrashRecovery`: Offer to Open the Latest Auto-Save After a Crash -- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch whose window never drew leaves a `launch-pending` marker in the settings folder, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. +- `startup.windowGraphics`: Window Graphics (`auto` / `gl`), what the desktop window draws with from the next launch. `auto` lets the platform pick (DirectX 12, Vulkan or Metal); `gl` uses OpenGL, with CPU compositing, for graphics drivers that crash with the others. A launch leaves a `launch-pending` marker in the settings folder until its window has been drawing for a few seconds (or the app quits normally before that); a launch that stops earlier (a graphics driver crashing as the window opens or as its surface is re-created) leaves the marker behind, and the next launch switches to `gl` and says so (not on macOS, which has no OpenGL backend). `WGPU_BACKEND` overrides both. - `project.useTemplate`: New Project Loads Template - `project.templatePath`: Template Project - `autoSave.enabled`: Automatically Save Projects From a91952dadc54a0a572a16b98a7f1bf7b3513c986 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:36:59 -0400 Subject: [PATCH 09/18] Effects: the .spimtx parser reads its rows with as_chunks clippy 1.99 (current stable, which CI tracks unpinned) fails the workspace gate on chunks_exact_to_as_chunks in parse_spimtx. The length is already checked to be 12, so as_chunks::<4>() has no remainder and the behaviour is unchanged; the workspace's rust-version (1.95) has it. Co-Authored-By: Claude Fable 5.1 --- crates/effects/src/ocio.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index d00f6e9eb..c8fcf848e 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option { } let mut m = [0.0; 16]; let mut offset = [0.0; 4]; - for (r, row) in v.chunks_exact(4).enumerate() { + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); offset[r] = row[3] / 65535.0; } From 2a404f6226791c132cb1c0fef13e4f1147f4e271 Mon Sep 17 00:00:00 2001 From: Jeremy Yao Date: Sat, 10 Oct 2026 09:37:00 -0400 Subject: [PATCH 10/18] MCP: the auto-save recovery tests look for the JSON-quoted path automation autosave_is_durable_before_reply_and_queries_do_not_rotate and the CLI's mcp_autosave_survives_kill_and_restart_without_touching_ desktop_settings asserted that the initialize instructions contain the previous session's auto-save path as raw text. The instructions embed the auto-save info as JSON, so on Windows the path's backslashes are escaped and the raw match can never succeed (both failed on every Windows run; the server's reply is right). They now look for the path as serde_json prints it, which also matches on Linux and macOS. Co-Authored-By: Claude Fable 5.1 --- apps/effectcraft-cli/tests/cli.rs | 4 +++- crates/automation/src/tests.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/effectcraft-cli/tests/cli.rs b/apps/effectcraft-cli/tests/cli.rs index 723016a86..51bdcc4ce 100644 --- a/apps/effectcraft-cli/tests/cli.rs +++ b/apps/effectcraft-cli/tests/cli.rs @@ -373,7 +373,9 @@ fn mcp_autosave_survives_kill_and_restart_without_touching_desktop_settings() { assert_eq!(manifest["ended"], false); let mut restarted = McpChild::start(&config, true); let init = restarted.rpc("initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions embed the auto-save info as JSON, so the path appears JSON-quoted (on + // Windows with its backslashes escaped). + assert!(init["instructions"].as_str().unwrap().contains(&json!(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); restarted.tool("open_project", json!({"path":path})); let comp = restarted.tool("get_comp", json!({"comp":"Survives restart"})); diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 44a61cc14..b4090ca7a 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -269,7 +269,9 @@ fn autosave_is_durable_before_reply_and_queries_do_not_rotate() { drop(s); // Abrupt process loss has no EOF cleanup; the checkpoint already exists. let mut restarted = server().with_autosave(&dir).unwrap(); let init = rpc(&mut restarted, 1, "initialize", json!({})); - assert!(init["instructions"].as_str().unwrap().contains(path)); + // The instructions embed the auto-save info as JSON, so the path appears JSON-quoted (on + // Windows with its backslashes escaped). + assert!(init["instructions"].as_str().unwrap().contains(&json!(path).to_string())); assert_eq!(init["_meta"]["effectcraftAutoSave"]["previousSessions"][0]["autosave"], path); call_json(&mut restarted, "open_project", json!({"path": path})); assert_eq!(call_json(&mut restarted, "get_project", json!({}))["items"][0]["name"], "Durable"); From adbe4dff4ee18294015ba2777c0a3a47efd03386 Mon Sep 17 00:00:00 2001 From: kikikikier <140857557+kikikikier@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:14:06 +0300 Subject: [PATCH 11/18] UI: support continuous numeric scrubbing across monitor edges --- crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/panels/comp_settings.rs | 14 +- crates/ui-egui/src/panels/dialogs.rs | 4 +- crates/ui-egui/src/panels/dialogs_3d.rs | 26 +- crates/ui-egui/src/panels/essential.rs | 12 +- crates/ui-egui/src/panels/forms.rs | 2 +- crates/ui-egui/src/panels/key_dialogs.rs | 2 +- .../ui-egui/src/panels/layer_styles_dialog.rs | 8 +- crates/ui-egui/src/panels/markers_ui.rs | 4 +- crates/ui-egui/src/panels/settings.rs | 4 +- crates/ui-egui/src/panels/tracker.rs | 5 +- crates/ui-egui/src/panels/viewer_tools.rs | 12 +- crates/ui-egui/src/pointer_wrap.rs | 462 ++++++++++++++++++ crates/ui-egui/src/widgets.rs | 10 + 14 files changed, 527 insertions(+), 46 deletions(-) create mode 100644 crates/ui-egui/src/pointer_wrap.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 82be8a323..0686dddca 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -23,6 +23,7 @@ pub mod menu_keys; pub mod menus; pub mod native_menu; pub mod panels; +mod pointer_wrap; pub mod prefs_live; pub mod state; pub mod theme; @@ -1559,7 +1560,7 @@ impl eframe::App for EffectcraftApp { self.session.end_recovery(); } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); @@ -1573,6 +1574,9 @@ impl eframe::App for EffectcraftApp { }; raw_input.events.extend(self.synthetic.drain(..n)); } + if pointer_wrap::prepare_input(ctx, raw_input) { + self.session.history.merge_key = None; + } } fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { @@ -1618,6 +1622,8 @@ impl eframe::App for EffectcraftApp { } self.frame(ui); let ctx = ui.ctx().clone(); + #[cfg(not(target_arch = "wasm32"))] + pointer_wrap::finish_native(&ctx, frame); // wasm32: no frame threads; render queued frames now, between UI frames. if cfg!(target_arch = "wasm32") && self.frames.pump(std::time::Duration::from_millis(if self.playback.playing { 24 } else { 40 })) { ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/comp_settings.rs b/crates/ui-egui/src/panels/comp_settings.rs index 39b7580ea..c94a8dab7 100644 --- a/crates/ui-egui/src/panels/comp_settings.rs +++ b/crates/ui-egui/src/panels/comp_settings.rs @@ -212,7 +212,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Width:")); ui.horizontal(|ui| { let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.width).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.width", r.rect, "Width"); let lbl = format!("Lock Aspect Ratio to {}", aspect_label(ow as f64, oh as f64)); let r = ui.checkbox(&mut d.lock_aspect, lbl); @@ -224,7 +224,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.end_row(); ui.label(tr("Height:")); let (ow, oh) = (d.width, d.height); - let r = ui.add(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.height).range(4..=30000).suffix(" px"))); auto.add("dialog.comp.height", r.rect, "Height"); if d.lock_aspect && oh != d.height && oh > 0 { d.width = ((d.height as f64) * ow as f64 / oh as f64).round().max(4.0) as u32; @@ -254,7 +254,7 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(tr("Frame Rate:")); ui.horizontal(|ui| { // Any rate can be typed; the list holds the common ones. - let r = ui.add(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.fps).range(1.0..=999.0).speed(0.01).max_decimals(3))); auto.add("dialog.comp.frameRate", r.rect, "Frame Rate"); let r = egui::ComboBox::from_id_salt("comp-fps").width(18.0).selected_text("").show_ui(ui, |ui| { for f in FRAME_RATES { @@ -320,19 +320,19 @@ pub fn show(ui: &mut egui::Ui, d: &mut CompDraft, t: &Tokens, auto: &mut Registr ui.label(egui::RichText::new(tr("Motion Blur")).strong()); ui.end_row(); ui.label(tr("Shutter Angle:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_angle).range(0.0..=720.0).suffix("°"))); auto.add("dialog.comp.shutterAngle", r.rect, "Shutter Angle"); ui.end_row(); ui.label(tr("Shutter Phase:")); - let r = ui.add(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.shutter_phase).range(-360.0..=360.0).suffix("°"))); auto.add("dialog.comp.shutterPhase", r.rect, "Shutter Phase"); ui.end_row(); ui.label(tr("Samples Per Frame:")); - let r = ui.add(egui::DragValue::new(&mut d.samples).range(2..=64)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.samples).range(2..=64))); auto.add("dialog.comp.samples", r.rect, "Samples Per Frame"); ui.end_row(); ui.label(tr("Adaptive Sample Limit:")); - let r = ui.add(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.adaptive_limit).range(16..=256))); auto.add("dialog.comp.adaptiveLimit", r.rect, "Adaptive Sample Limit"); ui.end_row(); }); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index f123906bb..ead96989b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -360,14 +360,14 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let (ow, oh) = (size[0], size[1]); ui.label(tr("Width:")); ui.horizontal(|ui| { - let r = ui.add(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.width", r.rect, "Width"); let r = ui.checkbox(&mut lock, tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))])); auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio"); }); ui.end_row(); ui.label(tr("Height:")); - let r = ui.add(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px"))); auto.add("dialog.solid.height", r.rect, "Height"); ui.end_row(); if lock && ow > 0 && oh > 0 { diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 485e2e8b5..6113c8662 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -188,13 +188,13 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Zoom")); ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.zoom).range(1.0..=100000.0).speed(1.0).suffix(" px"))); let mut aov = angle_of_view(w, d.zoom); - if ui.add(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut aov).range(0.1..=179.0).speed(0.1).suffix("°"))).changed() { d.zoom = w * 0.5 / (aov.to_radians() * 0.5).tan(); } let mut fl = focal_for_zoom(w, d.zoom); - if ui.add(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm")).changed() { + if ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut fl).range(0.1..=10000.0).speed(0.1).suffix(" mm"))).changed() { d.zoom = zoom_for_focal(w, fl); } }); @@ -204,15 +204,15 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); ui.label(tr("Focus Distance")); ui.horizontal(|ui| { - ui.add_enabled(!d.lock_to_zoom, egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px")); + ui.add_enabled(!d.lock_to_zoom, crate::widgets::drag_value(egui::DragValue::new(&mut d.focus).range(0.0..=100000.0).suffix(" px"))); ui.checkbox(&mut d.lock_to_zoom, tr("Lock to Zoom")); }); ui.end_row(); ui.label(tr("Aperture")); - ui.add(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.aperture).range(0.0..=10000.0).speed(0.1).suffix(" px"))); ui.end_row(); ui.label(tr("Blur Level")); - ui.add(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.blur).range(0.0..=10000.0).suffix(" %"))); ui.end_row(); }); ui.add_space(14.0); @@ -271,15 +271,15 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.color_edit_button_rgb(&mut d.color); ui.end_row(); ui.label(tr("Intensity")); - ui.add(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.intensity).range(-10000.0..=10000.0).suffix(" %"))); ui.end_row(); let kind = KINDS[d.kind.min(3)]; if kind == LightKind::Spot { ui.label(tr("Cone Angle")); - ui.add(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_angle).range(0.0..=180.0).suffix("°"))); ui.end_row(); ui.label(tr("Cone Feather")); - ui.add(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.cone_feather).range(0.0..=100.0).suffix(" %"))); ui.end_row(); } if kind != LightKind::Ambient { @@ -292,20 +292,20 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { ui.end_row(); if d.falloff > 0 { ui.label(tr("Radius")); - ui.add(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.radius).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); ui.label(tr("Falloff Distance")); - ui.add(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px")); + ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px"))); ui.end_row(); } ui.label(tr("Shadows")); ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows")); ui.end_row(); ui.label(tr("Shadow Darkness")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %"))); ui.end_row(); ui.label(tr("Shadow Diffusion")); - ui.add_enabled(d.casts_shadows, egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px")); + ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_diffusion).range(0.0..=1000.0).suffix(" px"))); ui.end_row(); } }); diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index 26d458d4e..c264e74ed 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -377,8 +377,8 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Point => { let mut a = v.as_vec2(); - let r1 = ui.add(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1)); - let r2 = ui.add(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1)); + let r1 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[0]).speed(1.0).max_decimals(1))); + let r2 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a[1]).speed(1.0).max_decimals(1))); if r1.changed() || r2.changed() { out = Some(json!(a)); } @@ -386,7 +386,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } ControlType::Angle => { let mut a = v.as_f64(); - let r = ui.add(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut a).speed(1.0).suffix("°").max_decimals(1))); if r.changed() { out = Some(json!(a)); } @@ -415,7 +415,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt } } }); - let r3 = ui.add(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1)); + let r3 = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.size).speed(0.5).range(1.0..=2000.0).suffix(" px").max_decimals(1))); app.auto.add(&format!("{auto}.font"), r1.response.rect, "Font"); app.auto.add(&format!("{auto}.style"), r2.response.rect, "Style"); app.auto.add(&format!("{auto}.size"), r3.rect, "Size"); @@ -428,7 +428,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt // One percentage for every axis. let comps = v.components(); let mut x = comps.first().copied().unwrap_or(100.0); - let r = ui.add(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).suffix("%").max_decimals(1))); if r.changed() { out = Some(json!(vec![x; comps.len().max(1)])); } @@ -438,7 +438,7 @@ fn value_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, auto: &str, ty: Opt let mut x = v.as_f64(); let r = match p.ui { ParamUi::Slider { slider_min, slider_max, .. } => ui.add(egui::Slider::new(&mut x, slider_min..=slider_max).max_decimals(2)), - _ => ui.add(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2)), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).speed(0.5).max_decimals(2))), }; if r.changed() { out = Some(json!(x)); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index a9468451d..889875534 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -769,7 +769,7 @@ pub fn show_form(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { let r = match &mut fl.kind { FieldKind::Number { value, speed } => { let dec = if *speed < 0.01 { 5 } else { 3 }; - ui.add(egui::DragValue::new(value).speed(*speed).max_decimals(dec)).rect + ui.add(crate::widgets::drag_value(egui::DragValue::new(value).speed(*speed).max_decimals(dec))).rect } FieldKind::Text(s) => ui.add(egui::TextEdit::singleline(s).desired_width(220.0)).rect, FieldKind::Path { value, .. } => { diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs index 3e170c12c..f4574a7df 100644 --- a/crates/ui-egui/src/panels/key_dialogs.rs +++ b/crates/ui-egui/src/panels/key_dialogs.rs @@ -126,7 +126,7 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str } fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) { - let r = ui.add_enabled(enabled, egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)); + let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix))); app.auto.add(id, r.rect, id); } diff --git a/crates/ui-egui/src/panels/layer_styles_dialog.rs b/crates/ui-egui/src/panels/layer_styles_dialog.rs index 7738fbea7..c6b6abbe3 100644 --- a/crates/ui-egui/src/panels/layer_styles_dialog.rs +++ b/crates/ui-egui/src/panels/layer_styles_dialog.rs @@ -103,10 +103,10 @@ fn control(ui: &mut egui::Ui, p: &Property, v: &KV) -> Option { ParamUi::Slider { slider_min, slider_max, decimals, .. } => { ui.add(egui::Slider::new(&mut y, *slider_min..=*slider_max).clamping(egui::SliderClamping::Never).max_decimals(*decimals as usize)) } - ParamUi::Percent => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("%")), - ParamUi::Angle => ui.add(egui::DragValue::new(&mut y).speed(0.5).suffix("°")), - ParamUi::Pixels => ui.add(egui::DragValue::new(&mut y).speed(0.25).suffix(" px")), - _ => ui.add(egui::DragValue::new(&mut y).speed(0.1)), + ParamUi::Percent => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("%"))), + ParamUi::Angle => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.5).suffix("°"))), + ParamUi::Pixels => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.25).suffix(" px"))), + _ => ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut y).speed(0.1))), }; (r.changed() && y != *x).then(|| json!(y)) } diff --git a/crates/ui-egui/src/panels/markers_ui.rs b/crates/ui-egui/src/panels/markers_ui.rs index 89a428a60..c36316046 100644 --- a/crates/ui-egui/src/panels/markers_ui.rs +++ b/crates/ui-egui/src/panels/markers_ui.rs @@ -128,11 +128,11 @@ pub fn dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { super::dialogs::modal(ctx, &title, vec2(520.0, 560.0), t, |ui| { egui::Grid::new("marker-grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tr("Time:")); - let r = ui.add(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.time).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.time", r.rect, "Time"); ui.end_row(); ui.label(tr("Duration:")); - let r = ui.add(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.duration).speed(0.01).range(0.0..=1e6).suffix(" s").max_decimals(3))); app.auto.add("dialog.marker.duration", r.rect, "Duration"); ui.end_row(); ui.label(tr("Comment:")); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index ac79d8f08..11350e07d 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -108,7 +108,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value let r = match kind { Kind::Int(lo, hi, unit) => { let mut x = v.as_i64().unwrap_or(0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } @@ -119,7 +119,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value } Kind::Float(lo, hi, unit) => { let mut x = v.as_f64().unwrap_or(0.0); - let r = ui.add(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3)); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut x).range(lo..=hi).speed(0.1).max_decimals(3))); if !unit.is_empty() { ui.label(RichText::new(unit).color(t.text_dim)); } diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs index 63ab06e80..13a5fa135 100644 --- a/crates/ui-egui/src/panels/tracker.rs +++ b/crates/ui-egui/src/panels/tracker.rs @@ -508,7 +508,8 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) ui.horizontal(|ui| { let r = ui.checkbox(&mut d.blur_on, crate::i18n::tr("Blur")); app.auto.add("dialog.trackOptions.blurOn", r.rect, "Blur"); - let r = ui.add_enabled(d.blur_on, egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels")); + let r = + ui.add_enabled(d.blur_on, crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.blur).speed(0.1).range(0.5..=50.0).suffix(" pixels"))); app.auto.add("dialog.trackOptions.blur", r.rect, "Blur pixels"); let r = ui.checkbox(&mut d.opts.enhance, crate::i18n::tr("Enhance")); app.auto.add("dialog.trackOptions.enhance", r.rect, "Enhance"); @@ -539,7 +540,7 @@ pub fn options_dialog(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) .response; app.auto.add("dialog.trackOptions.action", r.rect, "If Confidence is Below action"); ui.label(crate::i18n::tr("If Confidence is Below")); - let r = ui.add(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %")); + let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.opts.threshold).speed(0.5).range(0.0..=100.0).suffix(" %"))); app.auto.add("dialog.trackOptions.threshold", r.rect, "Confidence threshold"); }); (ok, cancel) = ok_cancel(ui, app, t, "dialog.trackOptions"); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 8d41eb738..c4f79ed47 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -675,11 +675,13 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, let mut ev = vo.exposure as f64; let resp = ui.put( er, - egui::DragValue::new(&mut ev) - .speed(0.05) - .range(-40.0..=40.0) - .fixed_decimals(1) - .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + crate::widgets::drag_value( + egui::DragValue::new(&mut ev) + .speed(0.05) + .range(-40.0..=40.0) + .fixed_decimals(1) + .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }), + ), ); let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)")); app.auto.add("viewer.exposure", er, "Adjust Exposure"); diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs new file mode 100644 index 000000000..bb36adca4 --- /dev/null +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -0,0 +1,462 @@ +//! 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_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. +pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput) -> 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_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 = 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 { + 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) { + let state = ctx.data(|d| d.get_temp::(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_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_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_id()).is_some_and(|s| s.numeric.is_some_and(|(_, p)| p == pass))) { + ctx.data_mut(|d| d.remove::(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, + standard: bool, + ordinary: bool, + modifiers: Modifiers, + } + + 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, + modifiers: Modifiers::NONE, + } + } + + fn frame(&mut self, events: Vec, 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); + let _ = 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 { + 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) + }); + }); + } + + 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 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"); + } + + #[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_textbox_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 textbox in [false, true] { + let mut session = effectcraft_host::session(); + // This patch also works without the optional TextBox plugin. + if textbox && effectcraft_engine::effects::plugin::plugin("org.effectcraft.text-box").is_none() { + continue; + } + 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(); + if textbox { + session.execute("effect.apply", json!({"layer": lid, "effect": "TextBox"})).unwrap(); + } + let path = if textbox { "effects/#1/paddingX" } 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 = if textbox { PanelKind::EffectControls } else { PanelKind::Properties }; + let id = if textbox { format!("effectControls.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 _ = 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) + }); + }); + warp + }; + for _ in 0..4 { + step(&mut app, vec![]); + } + for gesture in 1..=2 { + 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(10.0, 0.0))]); + let mut last = p; + for _ in 0..3 { + let target = step(&mut app, vec![Event::PointerMoved(pos2(1599.0, 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(10.0, 0.0); + step(&mut app, vec![Event::PointerMoved(last)]); + } + 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); + } + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52be..7b271caf1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -104,6 +104,7 @@ pub fn hot_number_at( return (er, out, out.is_some()); } let resp = ui.interact(rect, id, Sense::click_and_drag()); + crate::pointer_wrap::track(&resp); let col = if resp.hovered() || resp.dragged() { t.accent_hover } else { t.hot_text }; ui.painter().galley_with_override_text_color(rect.min + vec2(2.0, 2.0), galley, col); if resp.hovered() || resp.dragged() { @@ -154,6 +155,15 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s (rect, (step != 0.0).then(|| value.saturating_add(step as i64)), done) } +/// Standard numeric editors use the same continuous native scrubbing as hot text. +pub fn drag_value(value: egui::DragValue<'_>) -> impl egui::Widget { + move |ui: &mut Ui| { + let response = ui.add(value); + crate::pointer_wrap::track(&response); + response + } +} + /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it /// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. /// (Only then is the focus requested: a request resets what keys the field keeps for itself, From abd44404f87a51fb1d1cec478a1804c5db853544 Mon Sep 17 00:00:00 2001 From: kikikikier <140857557+kikikikier@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:14:06 +0300 Subject: [PATCH 12/18] UI: add inline font family search in Character and Properties --- crates/ui-egui/src/panels/properties.rs | 8 +- crates/ui-egui/src/panels/text_panels.rs | 193 ++++++++++++++++++----- crates/ui-egui/src/widgets.rs | 9 +- crates/ui-egui/tests/ui_font_search.rs | 107 +++++++++++++ 4 files changed, 269 insertions(+), 48 deletions(-) create mode 100644 crates/ui-egui/tests/ui_font_search.rs diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 2dcecc1d4..db879fe2e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -318,13 +318,7 @@ fn text_section( // Font family and style. let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); let pop = egui::Id::new("props-font-pop"); - if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add("properties.text.font", fr, "Font family"); - // Every installed family (built only while the menu is open: it can be long). - let fams: Vec = if widgets::popup_is_open(ui, pop) { effectcraft_engine::text_families() } else { vec![] }; - if let Some(f) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)).and_then(|i| fams.get(i)) { + if let Some(f) = super::text_panels::font_picker(app, ui, pop, fr, &doc.font, "properties.text.font", true) { set(actions, json!({"font": f})); } y += 28.0; diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 8e24a0835..2617ee85c 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -96,11 +96,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Font family + style. let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0)); let pop = egui::Id::new("char-font-pop"); - if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("char-font")).clicked() && enabled { - widgets::open_popup(ui, pop); - } - app.auto.add("character.font", fr, "Font family"); - if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) { + if let Some(f) = font_picker(app, ui, pop, fr, &doc.font, "character.font", enabled) { actions.push(json!({"font": f})); } y += 28.0; @@ -551,47 +547,166 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } } -/// The font menu: recent fonts first, names in English or the fonts' own language, and a -/// "Sample" preview in each font (Settings ▸ Type). -fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option { - if !widgets::popup_is_open(ui, id) { +#[derive(Clone, Default)] +struct FontSearch { + query: String, + selected: usize, +} + +/// Shared font menu for Character and Properties: search, recent fonts and previews. +pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option { + let state_id = id.with("search-state"); + app.auto.add(auto, rect, "Font family"); + if !widgets::popup_is_open(ui, id) || !enabled { + ui.data_mut(|d| d.remove::(state_id)); + if widgets::dropdown(ui, rect, current, &app.tokens, id.with("field")).clicked() && enabled { + widgets::open_popup(ui, id); + } return None; } let rows = effectcraft_engine::font_menu(&app.session.prefs); + let saved = ui.data(|d| d.get_temp::(state_id)); + let fresh = saved.is_none(); + let mut state = saved.unwrap_or_default(); + let search_id = id.with("search"); + let (up, down, enter, escape) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::Enter), + i.consume_key(egui::Modifiers::NONE, egui::Key::Escape), + ) + }); let preview = app.session.prefs.type_.font_preview; let t = app.tokens; - let seps = rows.iter().filter(|r| r.family.is_empty()).count(); - widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| { - ui.set_min_width(if preview { 300.0 } else { 180.0 }); - let mut chosen = None; - for r in &rows { - if r.family.is_empty() { - ui.separator(); - continue; + // The font field itself becomes editable. The popup contains only results. + let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max); + let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y)); + let search = ui.put(edit_rect, egui::TextEdit::singleline(&mut state.query).id(search_id).hint_text(current).font(Tokens::ui(12.0))); + app.auto.add(&format!("{auto}.search"), search.rect, "Search fonts"); + if fresh { + search.request_focus(); + } + ui.memory_mut(|m| m.set_focus_lock_filter(search_id, egui::EventFilter { vertical_arrows: true, escape: true, ..Default::default() })); + if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow")).clicked() { + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::(state_id); + }); + ui.memory_mut(|m| m.surrender_focus(search_id)); + return None; + } + app.auto.add(&format!("{auto}.toggle"), arrow, "Close font list"); + let mut chosen = None; + let screen = ui.ctx().content_rect(); + let below = screen.bottom() - rect.bottom() - 16.0; + let above = below < 100.0 && rect.top() - screen.top() > below; + let max_height = (if above { rect.top() - screen.top() - 16.0 } else { below }).clamp(28.0, 380.0); + let anchor = if above { rect.left_top() } else { rect.left_bottom() }; + let pivot = if above { Align2::LEFT_BOTTOM } else { Align2::LEFT_TOP }; + widgets::note_open_popup_list(ui.ctx()); + let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_width(rect.width().max(180.0)); + let query = state.query.trim().to_lowercase(); + let mut seen = std::collections::HashSet::new(); + let filtered: Vec<_> = rows + .iter() + .filter(|r| { + !r.family.is_empty() + && (query.is_empty() + || ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family))) + }) + .collect(); + if fresh { + state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0); } - let resp = ui.selectable_label(r.family == current, &r.display); - if preview && ui.is_rect_visible(resp.rect) { - let key = egui::Id::new(("font-preview", &r.family)); - let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { - Some(l) => l, - None => { - let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); - ui.data_mut(|d| d.insert_temp(key, l.clone())); - l - } - }; - let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + if search.changed() { + state.selected = 0; + } + let last = filtered.len().saturating_sub(1); + state.selected = state.selected.min(last); + if down { + state.selected = state.selected.saturating_add(1).min(last); + } + if up { + state.selected = state.selected.saturating_sub(1); + } + if enter { + chosen = filtered.get(state.selected).map(|r| r.family.clone()); + } + // Give the result list its actual height even when the previous query had + // only one row. A cached tiny Area must not collapse a reopened long list. + let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height); + ui.set_height(height); + let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height); + if search.changed() { + scroll = scroll.vertical_scroll_offset(0.0); + } + scroll.show(ui, |ui| { + if filtered.is_empty() { + let r = ui.label("No matching fonts"); + app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts"); } - } - if resp.clicked() { - chosen = Some(r.family.clone()); - } - } - chosen - }) + let mut was_recent = false; + for (index, r) in filtered.iter().enumerate() { + if query.is_empty() && was_recent && !r.recent { + ui.separator(); + } + was_recent = r.recent; + let row_width = ui.available_width(); + let label_width = if preview { row_width - 100.0 } else { row_width }; + let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, "")); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), index == state.selected, &r.display)); + let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height())); + ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text( + label_rect.left_center(), + Align2::LEFT_CENTER, + &r.display, + Tokens::ui(12.0), + t.text, + ); + if ui.is_rect_visible(resp.rect) { + app.auto.add(&format!("{auto}.option.{}", r.family), resp.rect, &r.display); + } + if index == state.selected && (fresh || up || down) { + resp.scroll_to_me(None); + } + if preview && ui.is_rect_visible(resp.rect) { + let key = egui::Id::new(("font-preview", &r.family)); + let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { + Some(l) => l, + None => { + let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); + ui.data_mut(|d| d.insert_temp(key, l.clone())); + l + } + }; + let o = pos2(resp.rect.max.x - 90.0, resp.rect.center().y + 5.0); + for poly in lines.iter() { + let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); + ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } + } + if resp.clicked() { + chosen = Some(r.family.clone()); + } + resp.on_hover_text(&r.display); + } + }); + }); + }); + let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); + if chosen.is_some() || outside || escape { + ui.data_mut(|d| { + d.insert_temp(id.with("open"), false); + d.remove::(state_id); + }); + ui.memory_mut(|m| m.surrender_focus(search_id)); + } else { + ui.data_mut(|d| d.insert_temp(state_id, state)); + } + chosen } /// Align panel: Align Layers to Selection / Composition, the six align buttons and the six diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 8f12e52be..42ec7360a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -520,8 +520,7 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui)); }); }); - let pass = ui.ctx().cumulative_pass_nr(); - ui.ctx().data_mut(|d| d.insert_temp(popup_list_pass_id(), pass)); + note_open_popup_list(ui.ctx()); let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p)); if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.data_mut(|d| d.insert_temp(id.with("open"), false)); @@ -529,6 +528,12 @@ pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r chosen } +/// Custom popup lists also hold the app's keyboard shortcuts while open. +pub(crate) fn note_open_popup_list(ctx: &egui::Context) { + let pass = ctx.cumulative_pass_nr(); + ctx.data_mut(|d| d.insert_temp(popup_list_pass_id(), pass)); +} + /// The pass a [`popup_list`] was last drawn in. fn popup_list_pass_id() -> egui::Id { egui::Id::new("popup-list-pass") diff --git a/crates/ui-egui/tests/ui_font_search.rs b/crates/ui-egui/tests/ui_font_search.rs new file mode 100644 index 000000000..e3da6ef91 --- /dev/null +++ b/crates/ui-egui/tests/ui_font_search.rs @@ -0,0 +1,107 @@ +//! Real keyboard/pointer coverage for the font menus in both text panels. +use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId}; +use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind}; +use egui::{Event, Key, Modifiers, PointerButton}; +use egui_kittest::Harness; +use serde_json::json; + +fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone(); + let pos = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0); + h.input_mut().events.push(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.run_steps(2); + } +} + +fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) { + for pressed in [true, false] { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } + h.run_steps(3); +} + +fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) { + h.input_mut().events.push(Event::Text(text.into())); + h.run_steps(3); +} + +#[test] +fn font_search_filters_selects_and_undoes_in_both_panels() { + for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)); + let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() { + builder.wgpu().build_eframe(move |_| EffectcraftApp::new(s)) + } else { + builder.build_eframe(move |_| EffectcraftApp::new(s)) + }; + h.state_mut().show_panel(panel); + h.run_steps(3); + click(&mut h, prefix); + assert!(h.state().auto.find(&format!("{prefix}.search")).is_some()); + assert!(effectcraft_ui_egui::widgets::any_menu_open(&h.ctx), "font search must hold app shortcuts like other popup lists"); + let field = h.state().auto.find(prefix).unwrap(); + let search = h.state().auto.find(&format!("{prefix}.search")).unwrap(); + assert!( + (field.rect[1] + field.rect[3] / 2.0 - search.rect[1] - search.rect[3] / 2.0).abs() < 1.0, + "font selection and search must occupy the same row" + ); + // Search gets focus on open. Mixed case and surrounding whitespace are accepted. + type_text(&mut h, " jEtBrAiNs "); + let rows = h.state().auto.query(&format!("{prefix}.option.")); + assert!(!rows.is_empty()); + assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains"))); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap(); + } + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters"); + key(&mut h, Key::Enter); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono"); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Keep this text"); + h.state_mut().session.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter"); + click(&mut h, prefix); + type_text(&mut h, "no_such_font_123456789"); + assert!(h.state().auto.query(&format!("{prefix}.option.")).is_empty()); + assert!(h.state().auto.find(&format!("{prefix}.empty")).is_some()); + key(&mut h, Key::Enter); + assert!(h.state().auto.find(&format!("{prefix}.search")).is_some(), "no result keeps menu open"); + key(&mut h, Key::Escape); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter"); + click(&mut h, prefix); + // Reopening clears the previous query, and mouse selection still works. + h.run_steps(2); + if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") { + h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap(); + } + assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1); + type_text(&mut h, "JetBrains"); + click(&mut h, &format!("{prefix}.option.JetBrains Mono")); + assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono"); + } +} + +#[test] +fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() { + let mut s = Session::default(); + s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap(); + let lid = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("text.edit", json!({"layer": lid})).unwrap(); + s.execute("text.setSelection", json!({"start": 0, "end": 5})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s)); + h.state_mut().show_panel(PanelKind::Character); + h.run_steps(3); + click(&mut h, "character.font"); + type_text(&mut h, "noto serif"); + key(&mut h, Key::ArrowDown); + key(&mut h, Key::ArrowUp); + key(&mut h, Key::Enter); + let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap(); + assert_eq!(doc.text, "First second"); + assert_eq!(doc.style_at(0).font, "Noto Serif"); + assert_eq!(doc.style_at(8).font, "Inter"); +} From 1c2bf2f157a2d853312559143303d4754db6471a Mon Sep 17 00:00:00 2001 From: kikikikier <140857557+kikikikier@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:26:29 +0300 Subject: [PATCH 13/18] Fix signed angle scrubbing across zero and monitor wraps --- crates/ui-egui/src/panels/fx_widgets.rs | 5 +- crates/ui-egui/src/pointer_wrap.rs | 85 +++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs index 036ae0701..7ea2c3b5c 100644 --- a/crates/ui-egui/src/panels/fx_widgets.rs +++ b/crates/ui-egui/src/panels/fx_widgets.rs @@ -51,7 +51,10 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals: let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t); let out = match (new_rev, new_deg) { (Some(r), _) => Some(r as f64 * 360.0 + deg), - // The degrees field shows the remainder's size; its sign stays. + // Scrubbing changes the full signed angle. Reapplying the displayed + // remainder's sign would reverse the next delta after crossing zero. + (None, Some(d)) if ui.ctx().is_being_dragged(id) => Some(v + (d - deg.abs())), + // Typing replaces the remainder's size and keeps its displayed sign. (None, Some(d)) => Some(rev as f64 * 360.0 + if deg < 0.0 || (deg == 0.0 && v < 0.0) { -d } else { d }), (None, None) => None, }; diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs index bb36adca4..fa4f09c44 100644 --- a/crates/ui-egui/src/pointer_wrap.rs +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -187,6 +187,8 @@ mod tests { warps: Vec, standard: bool, ordinary: bool, + angle: bool, + angle_rects: Option<(Rect, Rect)>, modifiers: Modifiers, } @@ -201,6 +203,8 @@ mod tests { warps: Vec::new(), standard: false, ordinary: false, + angle: false, + angle_rects: None, modifiers: Modifiers::NONE, } } @@ -224,6 +228,19 @@ mod tests { ); } 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, @@ -262,6 +279,42 @@ mod tests { } } + #[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(); @@ -385,13 +438,13 @@ mod tests { } #[test] - fn real_properties_and_textbox_drags_keep_each_gesture_in_one_undo_step() { + fn real_properties_angles_and_textbox_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 textbox in [false, true] { + for (textbox, angle) in [(false, false), (false, true), (true, false)] { let mut session = effectcraft_host::session(); // This patch also works without the optional TextBox plugin. if textbox && effectcraft_engine::effects::plugin::plugin("org.effectcraft.text-box").is_none() { @@ -402,13 +455,25 @@ mod tests { if textbox { session.execute("effect.apply", json!({"layer": lid, "effect": "TextBox"})).unwrap(); } - let path = if textbox { "effects/#1/paddingX" } else { "transform/position" }; + let path = if textbox { + "effects/#1/paddingX" + } else 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 = if textbox { PanelKind::EffectControls } else { PanelKind::Properties }; - let id = if textbox { format!("effectControls.prop.{uid}.value") } else { format!("properties.prop.{uid}.value.0") }; + let id = if textbox { + format!("effectControls.prop.{uid}.value") + } else 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); @@ -435,21 +500,27 @@ mod tests { 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(10.0, 0.0))]); + step(&mut app, vec![Event::PointerMoved(p + vec2(direction * 10.0, 0.0))]); let mut last = p; for _ in 0..3 { - let target = step(&mut app, vec![Event::PointerMoved(pos2(1599.0, p.y))]).expect("real property scrubs wrap"); + 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(10.0, 0.0); + 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:?}"); From 0a265131ce81f0654dcf87b2e009fffc0b06a554 Mon Sep 17 00:00:00 2001 From: sanyamsingh001 <314051937+sanyamsingh001@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:23:47 +1000 Subject: [PATCH 14/18] G3: preserve reachable history during trimming Co-authored-by: Codex --- crates/effects/src/ocio.rs | 2 +- crates/engine/src/history.rs | 17 +++++++---- crates/engine/src/tests_history.rs | 47 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index d00f6e9eb..c8fcf848e 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -372,7 +372,7 @@ pub fn parse_spimtx(text: &str) -> Option { } let mut m = [0.0; 16]; let mut offset = [0.0; 4]; - for (r, row) in v.chunks_exact(4).enumerate() { + for (r, row) in v.as_chunks::<4>().0.iter().enumerate() { m[r * 4..r * 4 + 3].copy_from_slice(&row[..3]); offset[r] = row[3] / 65535.0; } diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 366309693..6d905a834 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -206,19 +206,26 @@ impl History { self.branches.push(Branch { parent: from.clone(), steps }); } - /// Apply the undo-levels limit: drop the oldest working-line states, the branches that grew - /// from them, and the oldest branches when branches hold more states than `levels`. + /// Apply the undo-levels limit: keep the nearest past and future states on the working + /// line, drop branches whose parents were removed, and bound branch states separately. pub fn trim(&mut self, levels: usize, current: &Arc) { let levels = levels.max(1); if self.undo.len() > levels { let extra = self.undo.len() - levels; self.undo.drain(..extra); } - let mut total: usize = self.branches.iter().map(|b| b.steps.len()).sum(); - while total > levels && !self.branches.is_empty() { - total -= self.branches.remove(0).steps.len(); + if self.redo.len() > levels { + // The next redo is last: discard the farthest future states first. + let extra = self.redo.len() - levels; + self.redo.drain(..extra); } + // Orphans from truncated line states must not consume the reachable branch budget. self.prune(current); + while self.branch_states() > levels && !self.branches.is_empty() { + self.branches.remove(0); + // Removing a parent branch also removes its descendants before counting again. + self.prune(current); + } } /// Drop branches whose parent state is no longer in the history (`current` = the current diff --git a/crates/engine/src/tests_history.rs b/crates/engine/src/tests_history.rs index c67e7c14b..d53f6ef38 100644 --- a/crates/engine/src/tests_history.rs +++ b/crates/engine/src/tests_history.rs @@ -1,9 +1,13 @@ //! Branching history: undo then a new step keeps the undone line as a branch; the tree lists //! every state; jumping to any state (on any branch) restores it and keeps everything else. +use std::sync::Arc; + +use effectcraft_project::Project; use serde_json::{Value, json}; use crate::Session; +use crate::history::{Branch, History}; fn names(s: &Session) -> Vec { s.project.comps().next().map(|(_, c)| c.layers.iter().map(|l| l.name.clone()).collect()).unwrap_or_default() @@ -190,3 +194,46 @@ fn a_session_restored_with_unsaved_changes_stays_modified() { s.execute("edit.undo", json!({})).unwrap(); assert!(s.is_dirty(), "undo doesn't reach a saved state"); } + +#[test] +fn dropped_redo_parent_does_not_discard_reachable_branches() { + let state = || Arc::new(Project::default()); + let current = state(); + let futures: Vec<_> = (0..5).map(|_| state()).collect(); + let reachable = state(); + let mut history = History { + redo: futures.iter().rev().map(|p| ("future".into(), p.clone())).collect(), + branches: vec![ + Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] }, + Branch { parent: futures[4].clone(), steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] }, + ], + ..Default::default() + }; + history.trim(2, ¤t); + assert_eq!(history.redo.len(), 2); + assert_eq!(history.branches.len(), 1); + assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable)); + assert_eq!(history.branch_states(), 1); + assert!(history.tree(¤t).iter().any(|node| node.label == "reachable")); +} + +#[test] +fn evicted_branch_parent_does_not_discard_other_reachable_branches() { + let state = || Arc::new(Project::default()); + let current = state(); + let parent = state(); + let reachable = state(); + let mut history = History { + branches: vec![ + Branch { parent: current.clone(), steps: vec![("parent".into(), parent.clone())] }, + Branch { parent: current.clone(), steps: vec![("reachable".into(), reachable.clone())] }, + Branch { parent, steps: vec![("orphan1".into(), state()), ("orphan2".into(), state())] }, + ], + ..Default::default() + }; + history.trim(2, ¤t); + assert_eq!(history.branches.len(), 1); + assert!(Arc::ptr_eq(&history.branches[0].steps[0].1, &reachable)); + assert_eq!(history.branch_states(), 1); + assert_eq!(history.tree(¤t).len(), 2); +} From bb08a6acc970e7e1419867000c8f7529725b3f19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:28:38 +0000 Subject: [PATCH 15/18] Work area: an edit never clamps with crossed bounds (#605 follow-up) A project saved with an inverted work area, or a comp shorter than a frame, made Tick::clamp panic on the next start or end edit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/engine/src/commands/comp.rs | 5 +++-- crates/engine/src/tests_menu_cmds.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/comp.rs b/crates/engine/src/commands/comp.rs index 2d7dafa8b..bf1fd5306 100644 --- a/crates/engine/src/commands/comp.rs +++ b/crates/engine/src/commands/comp.rs @@ -182,10 +182,11 @@ fn work_area(s: &mut Session, p: &Value) -> Result { _ => {} } if let Some(b) = begin { - c.work_area.0 = b.clamp(Tick::ZERO, c.duration - fd); + // min/max, not clamp: the bounds cross on a comp shorter than a frame or an inverted area. + c.work_area.0 = b.min(c.duration - fd).max(Tick::ZERO); } if let Some(e) = end { - c.work_area.1 = e.clamp(c.work_area.0 + fd, c.duration); + c.work_area.1 = e.max(c.work_area.0 + fd).min(c.duration); } // A start on its own can land after the current end. Pull it back so the interval // still covers one frame, the same rule as `set: "begin"`. diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index c20e01593..df0af369d 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -196,6 +196,7 @@ fn separated_position_follows_canvas_resize_and_crop() { placed(&separated, b, 1.0, 50.0, 30.0); assert_eq!(opaque_bounds(&separated, Tick::ZERO), (70, 70, 25, 25, 34, 34, 100)); assert_eq!(opaque_bounds(&separated, Tick::from_seconds_f64(1.0)), (70, 70, 45, 25, 54, 34, 100)); +} /// Pixel-centre of the opaque samples, so a 10×10 solid centred on (11, 22) reports (11, 22). fn opaque_centroid(s: &Session) -> (f64, f64, u32) { @@ -399,6 +400,19 @@ fn lift_and_extract_work_area() { assert!((spans[1].0 - 2.0).abs() < 1e-6 && (spans[1].1 - 8.0).abs() < 1e-6, "{spans:?}"); } +/// A project saved with an inverted work area (by an older build) is edited without a panic: +/// `clamp` with crossed bounds would abort. +#[test] +fn an_inverted_saved_work_area_takes_an_end_edit() { + let mut s = comp(); + let cid = s.active_comp_id().unwrap(); + std::sync::Arc::make_mut(&mut s.project).comp_mut(cid).unwrap().work_area = (Tick::from_seconds_f64(9.0), Tick::from_seconds_f64(2.0)); + s.execute("comp.workArea", json!({"end": 1.0})).unwrap(); + s.execute("comp.workArea", json!({"start": 50.0})).unwrap(); + let c = s.active_comp().unwrap(); + assert!(c.work_area.0 >= Tick::ZERO && c.work_area.0 + c.frame_duration() <= c.work_area.1 && c.work_area.1 <= c.duration); +} + #[test] fn work_area_stays_ordered_when_the_start_passes_the_end() { let ordered = |s: &Session| { From 8cb8a416acdeb9706c5f5a653d639ab0753c5311 Mon Sep 17 00:00:00 2001 From: tylerjenningsw Date: Sat, 10 Oct 2026 10:52:20 -0400 Subject: [PATCH 16/18] Packaging: the legacy-folder fallback covers every release before this installer change (#466) v0.7.0 has no App Paths Path value either, so the comments no longer name a release. (cherry picked from commit 4ae87f1e8351fca3fb7b2d3395affa30203d838f) --- packaging/windows/effectcraft.wxs | 4 ++-- packaging/windows/test-msi.ps1 | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index f4710add1..6757c4460 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -31,8 +31,8 @@ diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 0bed8c437..7cdaef860 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -3,8 +3,8 @@ Install-test the EffectCraft MSI's folder handling with stub binaries (no Rust build). .DESCRIPTION - Builds the MSI around two stand-in executables as 0.0.1 the way 0.6.0 and earlier were (no App - Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine: + Builds the MSI around two stand-in executables as 0.0.1 the way releases before this installer + change are (no App Paths "Path" value), then as 0.0.2 and 0.0.3, validates them (ICE), and, silently and per-machine: 1. installs 0.0.1 into a folder with spaces and a non-ASCII name (INSTALLFOLDER=...), 2. upgrades to 0.0.2 without naming a folder: it must stay in that folder (found from the installed effectcraft.exe, as there is no Path value yet), @@ -60,7 +60,8 @@ New-Item -ItemType Directory -Force -Path $Bin | Out-Null Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe') [IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in') -# 0.0.1 stands in for 0.6.0 and earlier: the same package without the App Paths "Path" value. +# 0.0.1 stands in for releases before this installer change: the same package without the App +# Paths "Path" value. $Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs' $LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs' $pathValue = '(?m)^.*]*Name="Path"[^>]*/>\r?\n' From 2500719969c8b0ffd84b70e4011e89b5b6b8c38a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:30:09 +0000 Subject: [PATCH 17/18] UI: an agent's injected drag never warps the real cursor (#592 follow-up) Control-channel pointer events (ui.drag) scrub numbers like a mouse but turn off the edge wrap, so an agent never moves the user's cursor; the TextBox case of the drag test goes (that plug-in isn't in-tree). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/lib.rs | 5 +-- crates/ui-egui/src/pointer_wrap.rs | 52 ++++++++++++++---------------- 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c16b3fdbc..7cd7a2a54 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1627,7 +1627,8 @@ impl eframe::App for EffectcraftApp { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { - if !self.synthetic.is_empty() { + let injected = !self.synthetic.is_empty(); + if injected { // Pointer events go one per frame so egui sees press → moves → release as a real drag. let pointer = |e: &egui::Event| matches!(e, egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. }); let n = if pointer(&self.synthetic[0]) { @@ -1640,7 +1641,7 @@ impl eframe::App for EffectcraftApp { }; raw_input.events.extend(self.synthetic.drain(..n)); } - if pointer_wrap::prepare_input(ctx, raw_input) { + if pointer_wrap::prepare_input(ctx, raw_input, injected) { self.session.history.merge_key = None; } } diff --git a/crates/ui-egui/src/pointer_wrap.rs b/crates/ui-egui/src/pointer_wrap.rs index fa4f09c44..d4c29b17c 100644 --- a/crates/ui-egui/src/pointer_wrap.rs +++ b/crates/ui-egui/src/pointer_wrap.rs @@ -31,8 +31,10 @@ pub(crate) fn track(response: &Response) { /// 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. -pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput) -> bool { +/// 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| { @@ -46,7 +48,7 @@ pub(crate) fn prepare_input(ctx: &Context, raw: &mut RawInput) -> bool { // 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 = raw.events.iter().any(|e| matches!(e, Event::Touch { .. })); + 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, .. })) @@ -190,6 +192,7 @@ mod tests { angle: bool, angle_rects: Option<(Rect, Rect)>, modifiers: Modifiers, + injected: bool, } impl Scrub { @@ -206,6 +209,7 @@ mod tests { angle: false, angle_rects: None, modifiers: Modifiers::NONE, + injected: false, } } @@ -219,7 +223,7 @@ mod tests { ..Default::default() }; raw.events.push(Event::ModifiersChanged(self.modifiers)); - prepare_input(&self.ctx, &mut raw); + prepare_input(&self.ctx, &mut raw, self.injected); let _ = self.ctx.run_ui(raw, |ui| { if self.standard { ui.put( @@ -338,6 +342,17 @@ mod tests { 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(); @@ -438,42 +453,23 @@ mod tests { } #[test] - fn real_properties_angles_and_textbox_drags_keep_each_gesture_in_one_undo_step() { + 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 (textbox, angle) in [(false, false), (false, true), (true, false)] { + for angle in [false, true] { let mut session = effectcraft_host::session(); - // This patch also works without the optional TextBox plugin. - if textbox && effectcraft_engine::effects::plugin::plugin("org.effectcraft.text-box").is_none() { - continue; - } 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(); - if textbox { - session.execute("effect.apply", json!({"layer": lid, "effect": "TextBox"})).unwrap(); - } - let path = if textbox { - "effects/#1/paddingX" - } else if angle { - "transform/rotation" - } else { - "transform/position" - }; + 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 = if textbox { PanelKind::EffectControls } else { PanelKind::Properties }; - let id = if textbox { - format!("effectControls.prop.{uid}.value") - } else if angle { - format!("properties.prop.{uid}.value") - } else { - format!("properties.prop.{uid}.value.0") - }; + 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); From 362d299642afb11b23336a41a30e6d93a2556490 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 16:30:10 +0000 Subject: [PATCH 18/18] Fonts: No matching fonts goes through the interface catalog (#593 follow-up) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/ui-egui/src/i18n/ui.rs | 4 ++++ crates/ui-egui/src/panels/text_panels.rs | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index fdcaa27e6..9cb9d7365 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -320,6 +320,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "このホストからはデバイス一覧を取得できないため、システムの既定の出力を使用します。"), ("No layer selected", "レイヤーが選択されていません"), ("No matching effects or presets", "一致するエフェクトまたはプリセットはありません"), + ("No matching fonts", "一致するフォントはありません"), ("No model credits were built into this copy.", "このビルドにはモデルのクレジットが含まれていません。"), ("No nested compositions", "ネストされたコンポジションはありません"), ("No shortcut", "ショートカットなし"), @@ -751,6 +752,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "此主机无法获取设备列表;将使用系统默认输出。"), ("No layer selected", "未选择图层"), ("No matching effects or presets", "没有匹配的效果或预设"), + ("No matching fonts", "没有匹配的字体"), ("No model credits were built into this copy.", "此副本未内置模型致谢。"), ("No nested compositions", "没有嵌套合成"), ("No shortcut", "无快捷键"), @@ -1242,6 +1244,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("No device list from this host; the system default output is used.", "此主機無法獲取設備清單;將使用系統預設輸出。"), ("No layer selected", "未選取圖層"), ("No matching effects or presets", "沒有匹配的效果或預設集"), + ("No matching fonts", "沒有符合的字體"), ("No model credits were built into this copy.", "此副本未內置模型致謝。"), ("No nested compositions", "沒有嵌套合成"), ("No shortcut", "無快速鍵"), @@ -1729,6 +1732,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ), ("No layer selected", "Шар не виділено"), ("No matching effects or presets", "Не знайдено відповідних ефектів або наборів"), + ("No matching fonts", "Не знайдено відповідних шрифтів"), ("No model credits were built into this copy.", "Ця копія не містить відомостей про моделі."), ("No nested compositions", "Немає вкладених композицій"), ("No shortcut", "Немає клавіатурного скорочення"), diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 2617ee85c..fa64860ae 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -645,7 +645,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } scroll.show(ui, |ui| { if filtered.is_empty() { - let r = ui.label("No matching fonts"); + let r = ui.label(tr("No matching fonts")); app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts"); } let mut was_recent = false; @@ -655,7 +655,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: } was_recent = r.recent; let row_width = ui.available_width(); - let label_width = if preview { row_width - 100.0 } else { row_width }; + let label_width = if preview { (row_width - 100.0).max(40.0) } else { row_width }; let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, "")); resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), index == state.selected, &r.display)); let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));