From ddaf78c221d2d477a7fbaa8fe858f4ab53942214 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:21:48 +0200 Subject: [PATCH] Keep transform handles in reach when the picture is past the monitor edge The Program monitor's transform box drew and hit-tested its handles at their true positions only, and the painter and the ui clip stop at the picture area. A still moved to or past the frame edge left its resize handles off the monitor. Handles beyond the monitor now stop at its edge (the picture itself is not moved), and the box is drawn over the whole monitor rather than only the picture. Fixes #1393 Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/panels/transform_handles.rs | 77 +++++++++++++++---- crates/ui-egui/tests/transform_handles_ui.rs | 24 ++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/panels/transform_handles.rs b/crates/ui-egui/src/panels/transform_handles.rs index 6470544c..c31d989c 100644 --- a/crates/ui-egui/src/panels/transform_handles.rs +++ b/crates/ui-egui/src/panels/transform_handles.rs @@ -43,6 +43,9 @@ const BADGE_ARM: f32 = 5.0; const ROTATE_REACH: f32 = 24.0; /// The `merge` key of a drag's changes: one undo step, whichever parameters the drag changes. const MERGE: &str = "programTransform"; +/// How far inside the monitor's clip (which reaches 12 points past the picture area) a handle +/// that would be off the monitor stops, so that all of it shows and can be grabbed. +const REACH_INSET: f32 = 12.0 + CORNER; /// What a press on the box takes hold of. #[derive(Clone, Copy, Debug, PartialEq)] @@ -64,6 +67,9 @@ struct Placement { to_screen: Affine, /// The picture's box: TL, TR, BR, BL. quad: [Pos2; 4], + /// Where the eight handles (see [`handle_points`]) are drawn and grabbed: on the box, or at + /// the monitor's edge for those beyond it (see [`within`]). + handles: [Pos2; 8], anchor_at: Pos2, position: Vec2, anchor: Vec2, @@ -117,9 +123,19 @@ fn inside(quad: &[Pos2; 4], p: Pos2) -> bool { pos != neg } +/// The handles at `points` pulled into `view`, the part of the monitor on screen: a picture moved +/// past the monitor's edge keeps every handle in reach (#1393). The picture stays where it is; +/// only the squares stop at the edge. A `view` with no room leaves them where they are. +fn within(points: [Pos2; 8], view: Rect) -> [Pos2; 8] { + if !(view.is_finite() && view.is_positive()) { + return points; + } + points.map(|p| pos2(p.x.max(view.min.x).min(view.max.x), p.y.max(view.min.y).min(view.max.y))) +} + /// The box of effect `e` (Motion or Transform) of clip `it` at media time `mt`, on the monitor -/// picture `pic` showing a `frame`-sized sequence. -fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, pic: Rect, frame: (u32, u32)) -> Option { +/// picture `pic` showing a `frame`-sized sequence, with its handles kept inside `reach`. +fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, pic: Rect, reach: Rect, frame: (u32, u32)) -> Option { let seq = app.session.active_sequence()?; let project = &app.session.project; let size = filmcraft_render::source_size(project, it.item).unwrap_or(frame); @@ -156,6 +172,7 @@ fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, p outer, to_screen, quad, + handles: within(handle_points(&quad), reach), anchor_at, position, anchor, @@ -168,7 +185,7 @@ fn placement(app: &FilmcraftApp, it: &TrackItem, e: &EffectInstance, mt: Tick, p /// The selected effect (its clip and index) and its box, while it is Motion or Transform of a /// selected clip under the playhead. -fn selected_placement(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Option<(Target, &'static EffectDef, Placement)> { +fn selected_placement(app: &FilmcraftApp, pic: Rect, reach: Rect, frame: (u32, u32)) -> Option<(Target, &'static EffectDef, Placement)> { let sel = app.session.state.selected_effect.as_ref()?; if !app.session.state.selection.contains(&sel.clip) { return None; @@ -182,7 +199,7 @@ fn selected_placement(app: &FilmcraftApp, pic: Rect, frame: (u32, u32)) -> Optio let index = sel.index(&it.effects)?; let e = it.effects.get(index).filter(|e| e.enabled)?; let def = e.def().filter(|d| d.id == "motion" || d.id == "transform")?; - let pl = placement(app, it, e, it.source_time_at(ph), pic, frame)?; + let pl = placement(app, it, e, it.source_time_at(ph), pic, reach, frame)?; Some(((sel.clip, index), def, pl)) } @@ -190,7 +207,7 @@ fn grab_at(pl: &Placement, p: Pos2) -> Option { if (pl.anchor_at - p).length() <= ANCHOR_GRAB { return Some(Grab::Anchor); } - if let Some(n) = handle_points(&pl.quad).iter().position(|h| (*h - p).length() <= GRAB) { + if let Some(n) = pl.handles.iter().position(|h| (*h - p).length() <= GRAB) { return Some(Grab::Handle(n)); } if inside(&pl.quad, p) { @@ -282,24 +299,26 @@ fn angle_deg(v: egui::Vec2) -> f64 { f64::from(v.y.atan2(v.x).to_degrees()) } -fn draw(app: &mut FilmcraftApp, ui: &egui::Ui, pl: &Placement, pic: Rect, name: &str) { +/// Draws the box over the whole monitor (the ui's clip), not just the picture, so its handles show +/// beside the frame as well as on it. +fn draw(app: &mut FilmcraftApp, ui: &egui::Ui, pl: &Placement, name: &str) { let accent = app.tokens.accent; - let painter = ui.painter().with_clip_rect(pic.expand(12.0)); + let painter = ui.painter(); painter.add(egui::Shape::closed_line(pl.quad.to_vec(), Stroke::new(1.0, accent))); app.auto.add("program.transform.box", Rect::from_points(&pl.quad), name); let mid = centre(&pl.quad); // automation rects: the square inside the circle a press grabs in let grab = vec2(GRAB, GRAB) * std::f32::consts::SQRT_2; - for (n, c) in pl.quad.iter().enumerate() { + for (n, (q, c)) in pl.quad.iter().zip(&pl.handles).enumerate() { let hr = Rect::from_center_size(*c, vec2(CORNER, CORNER)); painter.rect_filled(hr, 0.0, Color32::WHITE); painter.rect_stroke(hr, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle); app.auto.add(&format!("program.transform.handle.{n}"), Rect::from_center_size(*c, grab), "scale handle"); // just outside the corner, away from the box's centre - let out = (*c - mid).normalized(); - app.auto.add(&format!("program.transform.rotate.{n}"), Rect::from_center_size(*c + out * 14.0, vec2(10.0, 10.0)), "rotate"); + let out = (*q - mid).normalized(); + app.auto.add(&format!("program.transform.rotate.{n}"), Rect::from_center_size(*q + out * 14.0, vec2(10.0, 10.0)), "rotate"); } - for (n, m) in handle_points(&pl.quad).iter().enumerate().skip(4) { + for (n, m) in pl.handles.iter().enumerate().skip(4) { painter.rect_filled(Rect::from_center_size(*m, vec2(EDGE, EDGE)), 0.0, Color32::WHITE); app.auto.add(&format!("program.transform.handle.{n}"), Rect::from_center_size(*m, grab), "scale handle"); } @@ -382,10 +401,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra let id = egui::Id::new("program-transform"); let drag_id = id.with("drag"); let double = ui.input(|i| i.pointer.button_double_clicked(egui::PointerButton::Primary)); - if let Some((sel, def, pl)) = selected_placement(app, pic, frame) { - draw(app, ui, &pl, pic, def.name); - // (as far again beyond the rotate zone, where a click lets go of the box) - let resp = ui.interact(Rect::from_points(&pl.quad).expand(ROTATE_REACH * 2.0), id, Sense::click_and_drag()); + // the monitor around the picture as well as on it (its clip reaches past the picture area) + let reach = ui.clip_rect().shrink(REACH_INSET); + if let Some((sel, def, pl)) = selected_placement(app, pic, reach, frame) { + draw(app, ui, &pl, def.name); + // (as far again beyond the rotate zone, where a click lets go of the box; and the handles + // held at the monitor's edge) + let area = Rect::from_points(&pl.quad).union(Rect::from_points(&pl.handles)); + let resp = ui.interact(area.expand(ROTATE_REACH * 2.0), id, Sense::click_and_drag()); // the left button only: a middle or right drag leaves the picture alone let left = egui::PointerButton::Primary; if resp.drag_started_by(left) { @@ -442,3 +465,27 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } crate::panels::effect_controls::run(app, ui.ctx(), actions); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn handles_past_the_monitor_stop_at_its_edge() { + let view = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 50.0)); + // a box from the middle of the view to well past its bottom-right corner + let quad = [pos2(50.0, 25.0), pos2(150.0, 25.0), pos2(150.0, 75.0), pos2(50.0, 75.0)]; + let h = within(handle_points(&quad), view); + assert_eq!(h[0], pos2(50.0, 25.0), "a handle on the monitor stays on the box"); + assert_eq!(h[1], pos2(100.0, 25.0)); + assert_eq!(h[2], pos2(100.0, 50.0)); + assert_eq!(h[3], pos2(50.0, 50.0)); + assert_eq!(h[5], pos2(100.0, 50.0)); + assert!(h.iter().all(|p| view.contains(*p))); + // no room to keep them in: left where they are, without crossed bounds + let none = Rect::from_min_max(pos2(10.0, 10.0), pos2(0.0, 0.0)); + assert_eq!(within(handle_points(&quad), none), handle_points(&quad)); + let nan = Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(100.0, 50.0)); + assert_eq!(within(handle_points(&quad), nan), handle_points(&quad)); + } +} diff --git a/crates/ui-egui/tests/transform_handles_ui.rs b/crates/ui-egui/tests/transform_handles_ui.rs index c3c772a5..5eb1f9e2 100644 --- a/crates/ui-egui/tests/transform_handles_ui.rs +++ b/crates/ui-egui/tests/transform_handles_ui.rs @@ -441,6 +441,30 @@ fn guides_masks_and_graphics_take_the_left_button_only() { d.drag_from(at, 40.0, 20.0); assert_ne!(project(&d), before, "the left button still moves the layer"); } +/// A picture moved past the edge of the frame (and the monitor) keeps its handles in reach: the +/// ones that would be off the monitor stop at its edge, and dragging them still scales (#1393). +#[test] +fn handles_stay_in_reach_when_the_picture_is_past_the_edge() { + let mut d = Driver::demo(); + let (clip, _) = d.first_clip(); + // centred on the frame's bottom-right corner: the right and bottom handles are beyond it + d.exec("effects.setParam", json!({"clip": clip, "effect": "motion", "param": "position", "value": [1920.0, 1080.0]})); + d.click("effectControls.effect.motion"); + let pic = d.rect("program.picture"); + let (cx, cy) = d.center("program.transform.handle.0"); + assert!(near((cx, cy), (pic[0] + pic[2] / 2.0, pic[1] + pic[3] / 2.0)), "the top-left corner is at the frame's centre"); + let (hx, hy) = d.center("program.transform.handle.2"); + assert!(hx < 1600.0 && hy < 980.0, "the bottom-right handle is on screen: ({hx}, {hy})"); + // (its true place is about 280 points right of the picture, past the monitor) + assert!(hx <= pic[0] + pic[2] + 40.0 && hy <= pic[1] + pic[3] + 40.0, "and on the monitor: ({hx}, {hy}), picture {pic:?}"); + let p0 = d.point(clip, "motion", "position"); + d.drag("program.transform.handle.2", -30.0, -30.0); + let s = d.num(clip, "motion", "scale"); + assert!(s < 95.0, "the bottom-right handle scales: {s}"); + assert_eq!(d.point(clip, "motion", "position"), p0, "the picture is not moved"); + d.shot("transform-past-the-edge"); +} + /// Anywhere just outside the box rotates, beside the middle of an edge as beside a corner. #[test] fn rotating_from_beside_an_edge() {