From f066d6359faf224086d18a518347f96aae2bbbf6 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:58:09 -0800 Subject: [PATCH 1/4] chore(quest): claim quest/m0/snapping.md Quest-Claim: 02b6deb84ba67f9c From c2bcc49565f722ff29b2847fe7af4e7f0e10cf2e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 20:32:58 -0800 Subject: [PATCH 2/4] feat(studio): drag items in the preview, snapping to the canvas, items, spacing, and a grid Co-Authored-By: Claude Opus 5.5 --- README.md | 10 +- quest/m0/README.md | 1 - quest/m0/snapping.md | 14 - rs/moq-studio/src/main.rs | 1 + rs/moq-studio/src/snap.rs | 554 ++++++++++++++++++++++++++++++++++++++ rs/moq-studio/src/ui.rs | 356 +++++++++++++++++++++++- 6 files changed, 909 insertions(+), 27 deletions(-) delete mode 100644 quest/m0/snapping.md create mode 100644 rs/moq-studio/src/snap.rs diff --git a/README.md b/README.md index 7201d55..22585fc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Every panel is a tab you can drag, split, or stack. | Panel | Use it to | | --- | --- | -| Preview, Program | See the scene you are editing and the one that is live. **Cut** puts the preview on program at once; **Fade** blends over the collection's fade time. | +| Preview, Program | See the scene you are editing and the one that is live, and drag items in the preview to place them. **Cut** puts the preview on program at once; **Fade** blends over the collection's fade time. | | Scenes | Pick the scene in preview, add one, or remove one. | | History | See every edit, and undo or redo back to any of them. | | Items | Place, show, hide, and order what the preview scene or the top layer draws, back to front. Select an item to filter or copy it. | @@ -47,6 +47,14 @@ Items panel shows. The **top layer** is drawn over every scene, in preview and on program, and stays on through a cut or fade: a downstream keyer for a logo or a lower third. +In the preview, click an item of whatever the Items panel shows to select it, +drag it to move it, and drag its edges or corners to resize it. A drag snaps +edges and centers to the canvas's edges and center, to the other items shown, +and to the grid while **Grid** is on, drawing a guide through what it lines up +with. A moved item also snaps to the spacing of its neighbors, marking the +equal spaces. Hold Ctrl (Cmd on macOS) to drag without snapping, or turn +**Snap** off. The snap and grid settings last until the studio quits. + Items showing the same source are **linked**: one capture, with the source's filters, wherever it is shown. A **clone** is a new source showing another's capture with filters of its own, so a camera can be shown faded in one scene diff --git a/quest/m0/README.md b/quest/m0/README.md index 2faecf1..07eb82f 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -91,7 +91,6 @@ repository. - [HTML/CSS overlays](/quest/m0/overlays.md) - script-free HTML and CSS graphics through Blitz natively and the DOM in the browser - [Animation and transitions](/quest/m0/animation.md) - move, slide, wipe, and stinger transitions, and keyframed item properties - [Video effects](/quest/m0/effects.md) - a curated effect set with CSS meanings, and ML effects baked into their source -- [Snapping and guides](/quest/m0/snapping.md) - preview moves snap to edges, centers, and other items - [Guided setup](/quest/m0/guided-setup.md) - first launch detects devices, measures the uplink, and proposes settings - [Destinations](/quest/m0/destinations.md) - RTMP, RTMPS, and WHIP push with per-destination codecs and shared encoders, on a UI-free egress library a hosted service can reuse - [Multiple canvases](/quest/m0/multi-canvas.md) - landscape and portrait canvases composite at once from shared sources diff --git a/quest/m0/snapping.md b/quest/m0/snapping.md deleted file mode 100644 index 846f781..0000000 --- a/quest/m0/snapping.md +++ /dev/null @@ -1,14 +0,0 @@ -# [S] Snapping and guides - -## Goal - -Moving and resizing items in `moq-studio`'s preview snaps to the canvas -edges and center, to other items' edges and centers, and to an optional grid, -with smart guides and equal-spacing hints drawn while dragging. A modifier key -turns snapping off. - -## Plan - -OBS only now adds axis snapping. Snapping is a preview interaction, so it -lives in the studio, not in moq-scene or the layout track. Moves and resizes -still go through the scene model's commands so they undo and autosave. diff --git a/rs/moq-studio/src/main.rs b/rs/moq-studio/src/main.rs index 2f8e4e8..82836d8 100644 --- a/rs/moq-studio/src/main.rs +++ b/rs/moq-studio/src/main.rs @@ -12,6 +12,7 @@ use anyhow::Context; mod autosave; mod engine; mod health; +mod snap; mod ui; #[derive(usage_rs::Cli, Clone)] diff --git a/rs/moq-studio/src/snap.rs b/rs/moq-studio/src/snap.rs new file mode 100644 index 0000000..9a42747 --- /dev/null +++ b/rs/moq-studio/src/snap.rs @@ -0,0 +1,554 @@ +//! Where an item dragged in the preview lands: the part of it the pointer +//! holds, and how it snaps to the canvas, other items, and a grid. +//! +//! Plain math in canvas pixels with no egui types, so it is tested here; the +//! preview only converts the pointer and draws the result. + +use moq_scene::{Canvas, Rect}; + +/// How close two positions are to count as lined up. Snapping makes them +/// equal up to float rounding; unsnapped drags never draw guides. +const EPSILON: f32 = 0.01; + +/// A direction on the canvas. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Axis { + X, + Y, +} + +impl Axis { + pub const ALL: [Self; 2] = [Self::X, Self::Y]; + + fn other(self) -> Self { + match self { + Self::X => Self::Y, + Self::Y => Self::X, + } + } + + /// This axis's coordinate of `point`. + fn of(self, point: [f32; 2]) -> f32 { + match self { + Self::X => point[0], + Self::Y => point[1], + } + } + + /// Where `rect` starts and ends along this axis. + fn span(self, rect: &Rect) -> (f32, f32) { + match self { + Self::X => (rect.x, rect.x + rect.width), + Self::Y => (rect.y, rect.y + rect.height), + } + } + + fn set_span(self, rect: &mut Rect, (start, end): (f32, f32)) { + match self { + Self::X => (rect.x, rect.width) = (start, end - start), + Self::Y => (rect.y, rect.height) = (start, end - start), + } + } + + fn length(self, canvas: &Canvas) -> f32 { + match self { + Self::X => canvas.width as f32, + Self::Y => canvas.height as f32, + } + } +} + +/// One edge of an item along an axis. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Side { + Start, + End, +} + +/// The part of an item a drag holds. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Grab { + /// The whole item, which moves. + Move, + /// An edge or corner, which resizes: the edge held along each axis. + Resize { x: Option, y: Option }, +} + +/// What a grab moves along one axis. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Moves { + Both, + Edge(Side), + Neither, +} + +impl Grab { + /// What a press at `point` grabs of `rect`: an edge or corner within + /// `reach` resizes, and the rest of the inside moves. Inside, an edge + /// reaches at most a third of the way across, so a small item still moves. + pub fn at(rect: &Rect, point: [f32; 2], reach: f32) -> Option { + let mut sides = [None; 2]; + for (axis, side) in Axis::ALL.into_iter().zip(&mut sides) { + let (start, end) = axis.span(rect); + let p = axis.of(point); + if p < start - reach || p > end + reach { + return None; + } + let inward = reach.min((end - start) / 3.0); + // How far inside each edge the point is, negative outside it. + let (from_start, from_end) = (p - start, end - p); + *side = match (from_start <= inward, from_end <= inward) { + (true, true) if from_start <= from_end => Some(Side::Start), + (true, true) => Some(Side::End), + (true, false) => Some(Side::Start), + (false, true) => Some(Side::End), + (false, false) => None, + }; + } + Some(match sides { + [None, None] => Self::Move, + [x, y] => Self::Resize { x, y }, + }) + } + + fn moves(self, axis: Axis) -> Moves { + let side = match (self, axis) { + (Self::Move, _) => return Moves::Both, + (Self::Resize { x, .. }, Axis::X) => x, + (Self::Resize { y, .. }, Axis::Y) => y, + }; + side.map_or(Moves::Neither, Moves::Edge) + } + + /// `rect` with what this grab holds dragged by `delta`. A resized edge + /// stops at the opposite one rather than turning the item inside out. + pub fn drag(self, rect: Rect, delta: [f32; 2]) -> Rect { + let mut rect = rect; + for axis in Axis::ALL { + shift(&mut rect, axis, self.moves(axis), axis.of(delta)); + } + rect + } +} + +/// Move what `moves` names of `rect` along `axis` by `by`. +fn shift(rect: &mut Rect, axis: Axis, moves: Moves, by: f32) { + let (start, end) = axis.span(rect); + let span = match moves { + Moves::Both => (start + by, end + by), + Moves::Edge(Side::Start) => ((start + by).min(end), end), + Moves::Edge(Side::End) => (start, (end + by).max(start)), + Moves::Neither => return, + }; + axis.set_span(rect, span); +} + +/// How a drag snaps. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Snap { + /// How far, in canvas pixels, an edge or center is pulled to a line it nears. + pub reach: f32, + /// The spacing of a grid to snap to, in canvas pixels, if any. + pub grid: Option, +} + +/// A dragged item once snapped, and the hints to draw while it is held. +#[derive(Clone, Debug, PartialEq)] +pub struct Snapped { + pub rect: Rect, + pub guides: Vec, + pub gaps: Vec, +} + +/// A line where the dragged item lines up with the canvas or another item. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Guide { + /// The axis the line marks a position on: an `X` guide is vertical. + pub axis: Axis, + /// The position on `axis`. + pub at: f32, + /// Where the line starts and ends on the other axis. + pub from: f32, + pub to: f32, +} + +/// The empty space along `axis` between two items, drawn when the dragged +/// item's space to a neighbor matches another. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Gap { + pub axis: Axis, + pub start: f32, + pub end: f32, + /// Where on the other axis to draw it. + pub at: f32, +} + +impl Snap { + /// Snap `rect`, which `grab` has dragged, to the canvas, the `others`, and + /// the grid. Along each axis, the nearest line within reach of a moving + /// edge or center wins, as does the spacing of a neighboring pair when the + /// whole item moves. + pub fn apply(&self, grab: Grab, rect: Rect, others: &[Rect], canvas: &Canvas) -> Snapped { + let mut rect = rect; + for axis in Axis::ALL { + let moves = grab.moves(axis); + let points = points(axis.span(&rect), moves); + let mut shifts: Vec = Vec::new(); + for line in lines(axis, others, canvas) { + shifts.extend(points.iter().map(|point| line - point)); + } + if let Some(grid) = self.grid { + shifts.extend(points.iter().map(|point| (point / grid).round() * grid - point)); + } + // An alignment wins a tie, since it comes first. + let best = shifts + .into_iter() + .chain(spacings(axis, &rect, others)) + .filter(|shift| shift.abs() <= self.reach) + .min_by(|a, b| a.abs().total_cmp(&b.abs())); + if let Some(best) = best { + shift(&mut rect, axis, moves, best); + } + } + + let mut guides = Vec::new(); + let mut gaps = Vec::new(); + for axis in Axis::ALL { + let moves = grab.moves(axis); + guides.extend(lined_up(axis, &rect, &points(axis.span(&rect), moves), others, canvas)); + if moves == Moves::Both { + gaps.extend(equal_gaps(axis, &rect, others)); + } + } + Snapped { rect, guides, gaps } + } +} + +/// The positions along an axis that `moves` carries: every edge and the +/// center when the whole item moves, or the one edge a resize holds. +fn points((start, end): (f32, f32), moves: Moves) -> Vec { + match moves { + Moves::Both => vec![start, (start + end) / 2.0, end], + Moves::Edge(Side::Start) => vec![start], + Moves::Edge(Side::End) => vec![end], + Moves::Neither => Vec::new(), + } +} + +/// The canvas's edges and center along `axis`. +fn canvas_lines(axis: Axis, canvas: &Canvas) -> [f32; 3] { + let length = axis.length(canvas); + [0.0, length / 2.0, length] +} + +/// An item's edges and center along `axis`. +fn rect_lines(axis: Axis, rect: &Rect) -> [f32; 3] { + let (start, end) = axis.span(rect); + [start, (start + end) / 2.0, end] +} + +/// Every line along `axis` a moving edge or center snaps to. +fn lines(axis: Axis, others: &[Rect], canvas: &Canvas) -> Vec { + let others = others.iter().flat_map(|other| rect_lines(axis, other)); + canvas_lines(axis, canvas).into_iter().chain(others).collect() +} + +/// A guide for every line along `axis` that one of `points` of `rect` sits +/// on, spanning the item and what it lines up with. Guides at the same +/// position merge. +fn lined_up(axis: Axis, rect: &Rect, points: &[f32], others: &[Rect], canvas: &Canvas) -> Vec { + let across = axis.other(); + let (from, to) = across.span(rect); + let mut guides: Vec = Vec::new(); + let mut add = |at: f32, (other_from, other_to): (f32, f32)| { + if !points.iter().any(|point| (point - at).abs() < EPSILON) { + return; + } + let (from, to) = (from.min(other_from), to.max(other_to)); + match guides.iter_mut().find(|guide| (guide.at - at).abs() < EPSILON) { + Some(guide) => (guide.from, guide.to) = (guide.from.min(from), guide.to.max(to)), + None => guides.push(Guide { axis, at, from, to }), + } + }; + for at in canvas_lines(axis, canvas) { + add(at, (0.0, across.length(canvas))); + } + for other in others { + for at in rect_lines(axis, other) { + add(at, across.span(other)); + } + } + guides +} + +/// The `others` beside `rect` along `axis`: those overlapping it across. +fn neighbors(axis: Axis, rect: &Rect, others: &[Rect]) -> Vec { + let (from, to) = axis.other().span(rect); + others + .iter() + .filter(|other| { + let (other_from, other_to) = axis.other().span(other); + other_from < to && other_to > from + }) + .copied() + .collect() +} + +/// The empty spaces along `axis` between consecutive `rects`, by start. +fn gaps(axis: Axis, mut rects: Vec) -> Vec { + rects.sort_by(|a, b| axis.span(a).0.total_cmp(&axis.span(b).0)); + rects + .windows(2) + .filter_map(|pair| { + let (_, start) = axis.span(&pair[0]); + let (end, _) = axis.span(&pair[1]); + let (a, b) = (axis.other().span(&pair[0]), axis.other().span(&pair[1])); + // The middle of where they overlap across, or of the space between. + let at = (a.0.max(b.0) + a.1.min(b.1)) / 2.0; + (end - start > EPSILON).then_some(Gap { axis, start, end, at }) + }) + .collect() +} + +/// The shifts along `axis` that space `rect` evenly with its neighbors: +/// centered between two, or as far past a pair as they are apart. +fn spacings(axis: Axis, rect: &Rect, others: &[Rect]) -> Vec { + let (start, end) = axis.span(rect); + let size = end - start; + let mut row = neighbors(axis, rect, others); + row.sort_by(|a, b| axis.span(a).0.total_cmp(&axis.span(b).0)); + let mut targets = Vec::new(); + for pair in row.windows(2) { + let ((first, first_end), (second, second_end)) = (axis.span(&pair[0]), axis.span(&pair[1])); + let space = second - first_end; + if space <= EPSILON { + continue; + } + if space >= size { + targets.push(first_end + (space - size) / 2.0); + } + targets.push(second_end + space); + targets.push(first - space - size); + } + targets + .into_iter() + .map(|target| target - start) + .filter(|&by| { + // Only a spacing the hints show, which an item in the way breaks. + let mut moved = *rect; + shift(&mut moved, axis, Moves::Both, by); + !equal_gaps(axis, &moved, others).is_empty() + }) + .collect() +} + +/// The spaces along `axis` beside `rect` that match another space between +/// its neighbors or on its other side, with the spaces they match. +fn equal_gaps(axis: Axis, rect: &Rect, others: &[Rect]) -> Vec { + let mut row = neighbors(axis, rect, others); + row.push(*rect); + let all = gaps(axis, row); + let (start, end) = axis.span(rect); + let mut shown: Vec = Vec::new(); + for own in all.iter().filter(|gap| gap.end == start || gap.start == end) { + let length = own.end - own.start; + let matches: Vec<&Gap> = all + .iter() + .filter(|gap| *gap != own && ((gap.end - gap.start) - length).abs() < EPSILON) + .collect(); + if matches.is_empty() { + continue; + } + for gap in std::iter::once(own).chain(matches) { + if !shown.contains(gap) { + shown.push(*gap); + } + } + } + shown +} + +#[cfg(test)] +mod tests { + use super::*; + + const CANVAS: Canvas = Canvas { + width: 1920, + height: 1080, + framerate: 30, + }; + + fn rect(x: f32, y: f32, width: f32, height: f32) -> Rect { + Rect { x, y, width, height } + } + + const SNAP: Snap = Snap { + reach: 10.0, + grid: None, + }; + + #[test] + fn grabs_edges_corners_and_inside() { + let item = rect(100.0, 100.0, 200.0, 100.0); + assert_eq!(Grab::at(&item, [200.0, 150.0], 8.0), Some(Grab::Move)); + assert_eq!(Grab::at(&item, [50.0, 150.0], 8.0), None); + // Just outside an edge still holds it. + let right = Grab::Resize { + x: Some(Side::End), + y: None, + }; + assert_eq!(Grab::at(&item, [305.0, 150.0], 8.0), Some(right)); + let corner = Grab::Resize { + x: Some(Side::Start), + y: Some(Side::Start), + }; + assert_eq!(Grab::at(&item, [96.0, 97.0], 8.0), Some(corner)); + } + + #[test] + fn a_small_item_still_moves() { + let item = rect(100.0, 100.0, 12.0, 12.0); + assert_eq!(Grab::at(&item, [106.0, 106.0], 8.0), Some(Grab::Move)); + let left = Grab::Resize { + x: Some(Side::Start), + y: None, + }; + assert_eq!(Grab::at(&item, [101.0, 106.0], 8.0), Some(left)); + } + + #[test] + fn a_resize_stops_at_the_opposite_edge() { + let item = rect(100.0, 100.0, 200.0, 100.0); + let left = Grab::Resize { + x: Some(Side::Start), + y: None, + }; + assert_eq!(left.drag(item, [500.0, 40.0]), rect(300.0, 100.0, 0.0, 100.0)); + let bottom_right = Grab::Resize { + x: Some(Side::End), + y: Some(Side::End), + }; + assert_eq!(bottom_right.drag(item, [10.0, -20.0]), rect(100.0, 100.0, 210.0, 80.0)); + } + + #[test] + fn moves_snap_to_the_canvas_center_and_edges() { + // Its center is 3 px right of the canvas's and its top 4 px below it. + let snapped = SNAP.apply(Grab::Move, rect(863.0, 4.0, 200.0, 100.0), &[], &CANVAS); + assert_eq!(snapped.rect, rect(860.0, 0.0, 200.0, 100.0)); + let guides = [ + Guide { + axis: Axis::X, + at: 960.0, + from: 0.0, + to: 1080.0, + }, + Guide { + axis: Axis::Y, + at: 0.0, + from: 0.0, + to: 1920.0, + }, + ]; + assert_eq!(snapped.guides, guides); + } + + #[test] + fn out_of_reach_stays_put() { + let item = rect(500.0, 700.0, 100.0, 100.0); + let snapped = SNAP.apply(Grab::Move, item, &[], &CANVAS); + assert_eq!(snapped.rect, item); + assert!(snapped.guides.is_empty()); + } + + #[test] + fn the_nearest_line_wins() { + // Its left edge is 6 px right of one item's right edge, and its right + // edge 2 px left of another's center. + let others = [rect(0.0, 0.0, 494.0, 50.0), rect(560.0, 300.0, 84.0, 50.0)]; + let snapped = SNAP.apply(Grab::Move, rect(500.0, 600.0, 100.0, 100.0), &others, &CANVAS); + assert_eq!(snapped.rect, rect(502.0, 600.0, 100.0, 100.0)); + // Spanning from the other item down through the dragged one. + let guide = Guide { + axis: Axis::X, + at: 602.0, + from: 300.0, + to: 700.0, + }; + assert_eq!(snapped.guides, [guide]); + } + + #[test] + fn a_resize_snaps_only_the_edge_it_holds() { + let others = [rect(0.0, 300.0, 405.0, 50.0)]; + let right = Grab::Resize { + x: Some(Side::End), + y: None, + }; + // The right edge at 398 snaps to the other's right edge at 405, though + // the left edge sits 2 px from the canvas edge. + let snapped = SNAP.apply(right, rect(2.0, 500.0, 396.0, 100.0), &others, &CANVAS); + assert_eq!(snapped.rect, rect(2.0, 500.0, 403.0, 100.0)); + assert_eq!(snapped.guides.len(), 1); + } + + #[test] + fn the_grid() { + let snap = Snap { + reach: 10.0, + grid: Some(64.0), + }; + // The left edge is 6 px from 256, and the top 2 px from 320. + let snapped = snap.apply(Grab::Move, rect(250.0, 322.0, 100.0, 100.0), &[], &CANVAS); + assert_eq!(snapped.rect, rect(256.0, 320.0, 100.0, 100.0)); + // The grid is drawn, so it adds no guides. + assert!(snapped.guides.is_empty()); + } + + #[test] + fn centers_between_two_neighbors() { + let others = [rect(100.0, 500.0, 100.0, 100.0), rect(700.0, 500.0, 100.0, 100.0)]; + // The space is 500 wide; centered, a 100-wide item sits at 400. + let snapped = SNAP.apply(Grab::Move, rect(395.0, 520.0, 100.0, 100.0), &others, &CANVAS); + assert_eq!(snapped.rect.x, 400.0); + assert_eq!(spaces(&snapped), [(200.0, 400.0), (500.0, 700.0)]); + } + + #[test] + fn repeats_the_spacing_of_a_pair() { + let others = [rect(110.0, 500.0, 100.0, 100.0), rect(310.0, 500.0, 100.0, 100.0)]; + // The pair is 100 apart, so the next one belongs at 510. + let snapped = SNAP.apply(Grab::Move, rect(517.0, 500.0, 50.0, 100.0), &others, &CANVAS); + assert_eq!(snapped.rect.x, 510.0); + assert_eq!(spaces(&snapped), [(410.0, 510.0), (210.0, 310.0)]); + // And before the pair, at 110 - 100 - 50. + let snapped = SNAP.apply(Grab::Move, rect(-44.0, 500.0, 50.0, 100.0), &others, &CANVAS); + assert_eq!(snapped.rect.x, -40.0); + assert_eq!(spaces(&snapped), [(10.0, 110.0), (210.0, 310.0)]); + } + + #[test] + fn spacing_ignores_items_in_another_row() { + let others = [rect(100.0, 0.0, 100.0, 100.0), rect(300.0, 0.0, 100.0, 100.0)]; + let snapped = SNAP.apply(Grab::Move, rect(507.0, 500.0, 50.0, 100.0), &others, &CANVAS); + assert_eq!(snapped.rect.x, 507.0); + assert!(snapped.gaps.is_empty()); + } + + #[test] + fn spacing_is_not_offered_through_an_item() { + // Repeating the pair's spacing would land on a third item. + let others = [ + rect(100.0, 500.0, 100.0, 100.0), + rect(300.0, 500.0, 100.0, 100.0), + rect(440.0, 500.0, 100.0, 100.0), + ]; + let snapped = SNAP.apply(Grab::Move, rect(503.0, 500.0, 50.0, 100.0), &others, &CANVAS); + assert_eq!(snapped.rect.x, 503.0); + assert!(snapped.gaps.is_empty()); + } + + fn spaces(snapped: &Snapped) -> Vec<(f32, f32)> { + snapped.gaps.iter().map(|gap| (gap.start, gap.end)).collect() + } +} diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index 05185cc..2c59b8d 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -8,10 +8,11 @@ use anyhow::Context; use egui::{Color32, Ui}; use egui_dock::{DockArea, DockState, NodeIndex, Style, TabViewer}; use moq_scene::source::Status; -use moq_scene::{Clip, Collection, Draft, Filter, Item, Layer, Rect, Scene, Source, Video, wgpu}; +use moq_scene::{Canvas, Clip, Collection, Draft, Filter, Item, Layer, Rect, Scene, Source, Video, wgpu}; use crate::engine::{Engine, State}; use crate::health::{self, Verdict}; +use crate::snap::{self, Grab, Snap}; /// The panels, each a tab the user can move, split, or stack. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -98,6 +99,8 @@ impl Studio { top: false, selection: None, gestures: Gestures::default(), + snapping: Snapping::default(), + drag: None, new_scene: String::new(), new_item: None, add: AddSource::default(), @@ -135,6 +138,9 @@ struct Panels { /// What the Filters panel edits and a copy takes. selection: Option, gestures: Gestures, + snapping: Snapping, + /// The item a drag in the preview holds. + drag: Option, new_scene: String, new_item: Option, add: AddSource, @@ -380,11 +386,13 @@ impl Panels { } } - /// Deselect an item, after an edit that may have moved any of them. + /// Deselect an item, after an edit that may have moved any of them, and + /// let go of any drag. fn deselect_item(&mut self) { if matches!(self.selection, Some(Selection::Item(..))) { self.selection = None; } + self.drag = None; } /// The layer the Items panel shows, and new sources are placed in. @@ -459,14 +467,162 @@ impl Panels { } fn preview(&mut self, ui: &mut Ui) { - let state = self.engine.state.lock().unwrap(); + let engine = self.engine.clone(); + let mut state = engine.state.lock().unwrap(); let canvas = state.collection().canvas; - ui.label(format!( - "Preview: {}", - state.compositor.preview_scene().unwrap_or("nothing") - )); + ui.horizontal(|ui| { + ui.label(format!( + "Preview: {}", + state.compositor.preview_scene().unwrap_or("nothing") + )); + ui.separator(); + let free = ui.ctx().format_modifiers(egui::Modifiers::COMMAND); + ui.checkbox(&mut self.snapping.on, "Snap").on_hover_text(format!( + "Snap a dragged item to the canvas, other items, and the grid. Hold {free} to drag freely." + )); + ui.checkbox(&mut self.snapping.grid, "Grid"); + let largest = canvas.width.max(canvas.height); + ui.add_enabled( + self.snapping.grid, + egui::DragValue::new(&mut self.snapping.spacing) + .range(2..=largest) + .suffix(" px"), + ); + }); + let response = picture(ui, self.preview, canvas, egui::Sense::click_and_drag()); + let result = self.arrange(ui, &mut state, &response, canvas); drop(state); - picture(ui, self.preview, canvas); + self.check(result); + } + + /// Select, move, and resize the items of the layer the Items panel shows + /// by dragging them in the preview, snapping as they go. + fn arrange(&mut self, ui: &Ui, state: &mut State, response: &egui::Response, canvas: Canvas) -> anyhow::Result<()> { + let gesture = self.gestures.of(response); + if !response.dragged() { + self.drag = None; + } + // A panel squeezed to nothing has no picture to map the pointer onto. + if !response.rect.is_positive() { + return Ok(()); + } + let view = View::new(response.rect, canvas); + let painter = ui.painter_at(response.rect); + if self.snapping.grid { + view.grid(&painter, self.snapping.spacing as f32); + } + + let Ok(layer) = self.layer(state) else { return Ok(()) }; + let Some(items) = state.collection().items(&layer).cloned() else { + return Ok(()); + }; + let selected = match &self.selection { + Some(Selection::Item(selected, index)) if *selected == layer && *index < items.len() => Some(*index), + _ => None, + }; + let reach = view.canvas(REACH); + // The selected item's edges first, since they reach past it, then the + // frontmost item shown. + let hit = |pos: egui::Pos2| { + let point = view.point(pos); + if let Some(index) = selected + && let Some(grab) = Grab::at(&items[index].rect, point, reach) + { + return Some((index, grab)); + } + let inside = |rect: &Rect| { + (rect.x..=rect.x + rect.width).contains(&point[0]) + && (rect.y..=rect.y + rect.height).contains(&point[1]) + }; + let index = (0..items.len()) + .rev() + .find(|&index| items[index].visible && inside(&items[index].rect))?; + Some((index, Grab::Move)) + }; + + if response.clicked() { + match response.interact_pointer_pos().and_then(hit) { + Some((index, _)) => self.selection = Some(Selection::Item(layer.clone(), index)), + None => self.deselect_item(), + } + } + if response.drag_started() + && let Some(origin) = ui.input(|input| input.pointer.press_origin()) + && let Some((index, grab)) = hit(origin) + { + self.selection = Some(Selection::Item(layer.clone(), index)); + self.drag = Some(Drag { + layer: layer.clone(), + index, + grab, + rect: items[index].rect, + origin: view.point(origin), + hints: None, + }); + } + + let mut result = Ok(()); + if let Some(drag) = &mut self.drag + && let Some(pos) = response.interact_pointer_pos() + { + let point = view.point(pos); + // Whole canvas pixels, so an unsnapped drag saves round numbers. + let delta = [(point[0] - drag.origin[0]).round(), (point[1] - drag.origin[1]).round()]; + let rect = drag.grab.drag(drag.rect, delta); + let free = ui.input(|input| input.modifiers.command); + drag.hints = (self.snapping.on && !free).then(|| { + let snap = Snap { + reach, + grid: self.snapping.grid.then_some(self.snapping.spacing as f32), + }; + snap.apply(drag.grab, rect, &others(state, &drag.layer, drag.index), &canvas) + }); + let rect = drag.hints.as_ref().map_or(rect, |snapped| snapped.rect); + let item = state + .collection() + .items(&drag.layer) + .and_then(|items| items.get(drag.index)); + if let Some(item) = item.cloned() + && item.rect != rect + { + let verb = if drag.grab == Grab::Move { "Move" } else { "Resize" }; + let label = format!("{verb} `{}`", item.source); + result = edit(state, label, gesture, |draft| { + draft.set_item(&drag.layer, drag.index, Item { rect, ..item }) + }); + } + } + + // Drawn from the collection and selection as this frame left them. + let items = state.collection().items(&layer).cloned().unwrap_or_default(); + let selected = match &self.selection { + Some(Selection::Item(selected, index)) if *selected == layer => Some(*index), + _ => None, + }; + let accent = ui.visuals().selection.stroke.color; + if let Some(item) = selected.and_then(|index| items.get(index)) { + view.selected(&painter, &item.rect, accent); + } + let grab = match &self.drag { + Some(drag) => { + if let Some(snapped) = &drag.hints { + view.hints(&painter, snapped); + } + Some(drag.grab) + } + None => response.hover_pos().and_then(hit).map(|(index, grab)| { + if selected != Some(index) + && let Some(item) = items.get(index) + { + view.hovered(&painter, &item.rect, accent); + } + grab + }), + }; + if let Some(grab) = grab { + ui.ctx().set_cursor_icon(cursor(grab)); + } + result } fn program(&mut self, ui: &mut Ui) { @@ -489,7 +645,7 @@ impl Panels { } }); drop(state); - picture(ui, self.program, canvas); + picture(ui, self.program, canvas, egui::Sense::hover()); } fn scenes(&mut self, ui: &mut Ui) { @@ -1281,16 +1437,194 @@ fn add( } /// Draw `texture` as large as fits, keeping the canvas's shape. -fn picture(ui: &mut Ui, texture: egui::TextureId, canvas: moq_scene::Canvas) { +fn picture(ui: &mut Ui, texture: egui::TextureId, canvas: Canvas, sense: egui::Sense) -> egui::Response { let available = ui.available_size(); let aspect = canvas.width as f32 / canvas.height as f32; let mut size = egui::vec2(available.x, available.x / aspect); if size.y > available.y { size = egui::vec2(available.y * aspect, available.y); } - ui.vertical_centered(|ui| ui.image((texture, size))); + ui.vertical_centered(|ui| ui.add(egui::Image::new((texture, size)).sense(sense))) + .inner +} + +/// How the preview snaps a dragged item, for this session. +struct Snapping { + on: bool, + /// Whether the grid is drawn and snapped to. + grid: bool, + /// The grid's spacing in canvas pixels. + spacing: u32, +} + +impl Default for Snapping { + fn default() -> Self { + Self { + on: true, + grid: false, + spacing: 40, + } + } +} + +/// An item a drag in the preview holds. +struct Drag { + layer: Layer, + index: usize, + grab: Grab, + /// The item's rect, and where the pointer was in canvas pixels, when the + /// drag started. Each frame drags from these, so snaps never accumulate. + rect: Rect, + origin: [f32; 2], + /// The snapped rect, its guides, and its spacing, unless snapping is off. + hints: Option, +} + +/// How far, in points on screen, the pointer reaches to an edge or a line. +const REACH: f32 = 8.0; + +/// The visible items of the scene in preview and the top layer, other than +/// the one at `index` of `layer`: what a dragged item snaps to. +fn others(state: &State, layer: &Layer, index: usize) -> Vec { + let scene = state + .compositor + .preview_scene() + .map(|name| Layer::Scene(name.to_string())); + let collection = state.collection(); + let mut rects = Vec::new(); + for shown in scene.into_iter().chain([Layer::Top]) { + let items = collection.items(&shown).map_or(&[][..], Vec::as_slice); + for (at, item) in items.iter().enumerate() { + if item.visible && !(shown == *layer && at == index) { + rects.push(item.rect); + } + } + } + rects +} + +fn cursor(grab: Grab) -> egui::CursorIcon { + use snap::Side::{End, Start}; + match grab { + Grab::Resize { x: Some(_), y: None } => egui::CursorIcon::ResizeHorizontal, + Grab::Resize { x: None, y: Some(_) } => egui::CursorIcon::ResizeVertical, + Grab::Resize { + x: Some(Start), + y: Some(Start), + } + | Grab::Resize { + x: Some(End), + y: Some(End), + } => egui::CursorIcon::ResizeNwSe, + Grab::Resize { x: Some(_), y: Some(_) } => egui::CursorIcon::ResizeNeSw, + Grab::Move | Grab::Resize { x: None, y: None } => egui::CursorIcon::Move, + } +} + +/// The preview's picture on screen, mapping canvas pixels to points. +struct View { + rect: egui::Rect, + /// Canvas pixels per point. + scale: f32, +} + +impl View { + fn new(rect: egui::Rect, canvas: Canvas) -> Self { + Self { + rect, + scale: canvas.width as f32 / rect.width(), + } + } + + /// The canvas position under `pos`. + fn point(&self, pos: egui::Pos2) -> [f32; 2] { + let offset = (pos - self.rect.min) * self.scale; + [offset.x, offset.y] + } + + /// A distance on screen, in canvas pixels. + fn canvas(&self, points: f32) -> f32 { + points * self.scale + } + + fn pos(&self, x: f32, y: f32) -> egui::Pos2 { + self.rect.min + egui::vec2(x, y) / self.scale + } + + fn screen(&self, rect: &Rect) -> egui::Rect { + egui::Rect::from_two_pos( + self.pos(rect.x, rect.y), + self.pos(rect.x + rect.width, rect.y + rect.height), + ) + } + + /// The line at `at` on `axis`, from `from` to `to` across it. + fn line(&self, axis: snap::Axis, at: f32, from: f32, to: f32) -> [egui::Pos2; 2] { + match axis { + snap::Axis::X => [self.pos(at, from), self.pos(at, to)], + snap::Axis::Y => [self.pos(from, at), self.pos(to, at)], + } + } + + fn grid(&self, painter: &egui::Painter, spacing: f32) { + // Any finer on screen, the grid is a wash. + if spacing / self.scale < 4.0 { + return; + } + let stroke = egui::Stroke::new(1.0, Color32::from_white_alpha(32)); + let size = self.rect.size() * self.scale; + for (axis, length, across) in [(snap::Axis::X, size.x, size.y), (snap::Axis::Y, size.y, size.x)] { + let mut at = spacing; + while at < length { + painter.line_segment(self.line(axis, at, 0.0, across), stroke); + at += spacing; + } + } + } + + /// An outline with a handle at each corner and edge. + fn selected(&self, painter: &egui::Painter, rect: &Rect, color: Color32) { + let rect = self.screen(rect); + painter.rect_stroke(rect, 0.0, egui::Stroke::new(1.5, color), egui::StrokeKind::Outside); + for x in [rect.left(), rect.center().x, rect.right()] { + for y in [rect.top(), rect.center().y, rect.bottom()] { + if (x, y) != (rect.center().x, rect.center().y) { + let handle = egui::Rect::from_center_size(egui::pos2(x, y), egui::Vec2::splat(6.0)); + painter.rect_filled(handle, 1.0, color); + } + } + } + } + + fn hovered(&self, painter: &egui::Painter, rect: &Rect, color: Color32) { + let stroke = egui::Stroke::new(1.0, color.gamma_multiply(0.6)); + painter.rect_stroke(self.screen(rect), 0.0, stroke, egui::StrokeKind::Outside); + } + + /// The guides the dragged item lines up on, and the equal spaces beside it. + fn hints(&self, painter: &egui::Painter, snapped: &snap::Snapped) { + let stroke = egui::Stroke::new(1.0, GUIDE); + for guide in &snapped.guides { + painter.line_segment(self.line(guide.axis, guide.at, guide.from, guide.to), stroke); + } + for gap in &snapped.gaps { + // A gap along X is a horizontal span: a line at its `at` on Y. + let (across, tick) = match gap.axis { + snap::Axis::X => (snap::Axis::Y, egui::vec2(0.0, 4.0)), + snap::Axis::Y => (snap::Axis::X, egui::vec2(4.0, 0.0)), + }; + let [start, end] = self.line(across, gap.at, gap.start, gap.end); + painter.line_segment([start, end], stroke); + for pos in [start, end] { + painter.line_segment([pos - tick, pos + tick], stroke); + } + } + } } +/// Smart guides and spacing hints, bright enough to read over any picture. +const GUIDE: Color32 = Color32::from_rgb(255, 64, 160); + fn describe(source: &Source) -> String { let or_default = |id: &Option| id.clone().unwrap_or_else(|| "default".into()); match source { From 699b8ed8fd7db4d8c9ef1e81a8da93530e2720ed Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 22:15:02 -0800 Subject: [PATCH 3/4] fix(studio): spacing snaps only moves, backdrops yield to items in front, and the release frame lands Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/snap.rs | 16 ++++++++++++++-- rs/moq-studio/src/ui.rs | 17 +++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/rs/moq-studio/src/snap.rs b/rs/moq-studio/src/snap.rs index 9a42747..9ebf0e8 100644 --- a/rs/moq-studio/src/snap.rs +++ b/rs/moq-studio/src/snap.rs @@ -200,10 +200,13 @@ impl Snap { if let Some(grid) = self.grid { shifts.extend(points.iter().map(|point| (point / grid).round() * grid - point)); } - // An alignment wins a tie, since it comes first. + // Spacing places the whole item, so a resize never takes it. An + // alignment wins a tie, since it comes first. + if moves == Moves::Both { + shifts.extend(spacings(axis, &rect, others)); + } let best = shifts .into_iter() - .chain(spacings(axis, &rect, others)) .filter(|shift| shift.abs() <= self.reach) .min_by(|a, b| a.abs().total_cmp(&b.abs())); if let Some(best) = best { @@ -512,6 +515,15 @@ mod tests { let snapped = SNAP.apply(Grab::Move, rect(395.0, 520.0, 100.0, 100.0), &others, &CANVAS); assert_eq!(snapped.rect.x, 400.0); assert_eq!(spaces(&snapped), [(200.0, 400.0), (500.0, 700.0)]); + // Resizing the same item is not pulled by the spacing it would have moved to. + let right = Grab::Resize { + x: Some(Side::End), + y: None, + }; + let item = rect(395.0, 520.0, 100.0, 100.0); + let snapped = SNAP.apply(right, item, &others, &CANVAS); + assert_eq!(snapped.rect, item); + assert!(snapped.gaps.is_empty()); } #[test] diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index df0f35d..3b6e1a5 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -499,7 +499,8 @@ impl Panels { /// by dragging them in the preview, snapping as they go. fn arrange(&mut self, ui: &Ui, state: &mut State, response: &egui::Response, canvas: Canvas) -> anyhow::Result<()> { let gesture = self.gestures.of(response); - if !response.dragged() { + // The release still moves the item to where the pointer let go. + if !response.dragged() && !response.drag_stopped() { self.drag = None; } // A panel squeezed to nothing has no picture to map the pointer onto. @@ -522,11 +523,12 @@ impl Panels { }; let reach = view.canvas(REACH); // The selected item's edges first, since they reach past it, then the - // frontmost item shown. + // frontmost item shown, so a selected backdrop does not hide what is + // in front of it. let hit = |pos: egui::Pos2| { let point = view.point(pos); if let Some(index) = selected - && let Some(grab) = Grab::at(&items[index].rect, point, reach) + && let Some(grab @ Grab::Resize { .. }) = Grab::at(&items[index].rect, point, reach) { return Some((index, grab)); } @@ -557,6 +559,7 @@ impl Panels { grab, rect: items[index].rect, origin: view.point(origin), + gesture, hints: None, }); } @@ -587,11 +590,14 @@ impl Panels { { let verb = if drag.grab == Grab::Move { "Move" } else { "Resize" }; let label = format!("{verb} `{}`", item.source); - result = edit(state, label, gesture, |draft| { + result = edit(state, label, drag.gesture, |draft| { draft.set_item(&drag.layer, drag.index, Item { rect, ..item }) }); } } + if !response.dragged() { + self.drag = None; + } // Drawn from the collection and selection as this frame left them. let items = state.collection().items(&layer).cloned().unwrap_or_default(); @@ -1493,6 +1499,9 @@ struct Drag { /// drag started. Each frame drags from these, so snaps never accumulate. rect: Rect, origin: [f32; 2], + /// Kept from the start, since the frame that releases the drag no longer + /// reports it but still moves the item. + gesture: Option, /// The snapped rect, its guides, and its spacing, unless snapping is off. hints: Option, } From 59b3b6b93a33f8be03249f1f8bae77325b9fd8ff Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 06:50:51 -0800 Subject: [PATCH 4/4] fix(studio): the preview neither grabs nor outlines a hidden selected item Co-Authored-By: Claude Opus 5.5 --- rs/moq-studio/src/snap.rs | 51 ++++++++++++++++++++++++++++++++++++++- rs/moq-studio/src/ui.rs | 25 +++---------------- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/rs/moq-studio/src/snap.rs b/rs/moq-studio/src/snap.rs index 9ebf0e8..b65e185 100644 --- a/rs/moq-studio/src/snap.rs +++ b/rs/moq-studio/src/snap.rs @@ -4,7 +4,7 @@ //! Plain math in canvas pixels with no egui types, so it is tested here; the //! preview only converts the pointer and draws the result. -use moq_scene::{Canvas, Rect}; +use moq_scene::{Canvas, Item, Rect}; /// How close two positions are to count as lined up. Snapping makes them /// equal up to float rounding; unsnapped drags never draw guides. @@ -143,6 +143,29 @@ fn shift(rect: &mut Rect, axis: Axis, moves: Moves, by: f32) { axis.set_span(rect, span); } +/// The item of `items` a press at `point` grabs, and what part of it. The +/// `selected` item's edges come first, since they reach past it; then the +/// frontmost item under the point, so a selected backdrop does not hide what +/// is in front of it. Hidden items are not shown, so nothing grabs them. +pub fn hit(items: &[Item], selected: Option, point: [f32; 2], reach: f32) -> Option<(usize, Grab)> { + if let Some(index) = selected + && let Some(item) = items.get(index).filter(|item| item.visible) + && let Some(grab @ Grab::Resize { .. }) = Grab::at(&item.rect, point, reach) + { + return Some((index, grab)); + } + let inside = |rect: &Rect| { + Axis::ALL.into_iter().all(|axis| { + let (start, end) = axis.span(rect); + (start..=end).contains(&axis.of(point)) + }) + }; + let index = (0..items.len()) + .rev() + .find(|&index| items[index].visible && inside(&items[index].rect))?; + Some((index, Grab::Move)) +} + /// How a drag snaps. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Snap { @@ -547,6 +570,32 @@ mod tests { assert!(snapped.gaps.is_empty()); } + #[test] + fn hits_the_selected_edges_then_the_front() { + let backdrop = Item::new("backdrop", rect(0.0, 0.0, 1920.0, 1080.0)); + let mut hidden = Item::new("hidden", rect(500.0, 500.0, 200.0, 200.0)); + hidden.visible = false; + let front = Item::new("front", rect(400.0, 400.0, 400.0, 400.0)); + let items = [backdrop, hidden, front]; + let right = Grab::Resize { + x: Some(Side::End), + y: None, + }; + + // A selected backdrop's inside yields to the item in front of it. + assert_eq!(hit(&items, Some(0), [600.0, 600.0], 8.0), Some((2, Grab::Move))); + assert_eq!(hit(&items, Some(0), [1915.0, 600.0], 8.0), Some((0, right))); + // The selected item's edge reaches past the item in front of it. + let mut selected = items.clone(); + selected[0].rect = rect(0.0, 0.0, 600.0, 1080.0); + assert_eq!(hit(&selected, Some(0), [603.0, 600.0], 8.0), Some((0, right))); + // A hidden selected item's edge, inside a visible item, grabs that item. + assert_eq!(hit(&items, Some(1), [700.0, 600.0], 8.0), Some((2, Grab::Move))); + // Outside everything shown, nothing. + let items = &items[1..2]; + assert_eq!(hit(items, Some(0), [700.0, 600.0], 8.0), None); + } + #[test] fn spacing_is_not_offered_through_an_item() { // Repeating the pair's spacing would land on a third item. diff --git a/rs/moq-studio/src/ui.rs b/rs/moq-studio/src/ui.rs index ffbc59d..df67c3c 100644 --- a/rs/moq-studio/src/ui.rs +++ b/rs/moq-studio/src/ui.rs @@ -518,29 +518,11 @@ impl Panels { return Ok(()); }; let selected = match &self.selection { - Some(Selection::Item(selected, index)) if *selected == layer && *index < items.len() => Some(*index), + Some(Selection::Item(selected, index)) if *selected == layer => Some(*index), _ => None, }; let reach = view.canvas(REACH); - // The selected item's edges first, since they reach past it, then the - // frontmost item shown, so a selected backdrop does not hide what is - // in front of it. - let hit = |pos: egui::Pos2| { - let point = view.point(pos); - if let Some(index) = selected - && let Some(grab @ Grab::Resize { .. }) = Grab::at(&items[index].rect, point, reach) - { - return Some((index, grab)); - } - let inside = |rect: &Rect| { - (rect.x..=rect.x + rect.width).contains(&point[0]) - && (rect.y..=rect.y + rect.height).contains(&point[1]) - }; - let index = (0..items.len()) - .rev() - .find(|&index| items[index].visible && inside(&items[index].rect))?; - Some((index, Grab::Move)) - }; + let hit = |pos: egui::Pos2| snap::hit(&items, selected, view.point(pos), reach); if response.clicked() { match response.interact_pointer_pos().and_then(hit) { @@ -606,7 +588,8 @@ impl Panels { _ => None, }; let accent = ui.visuals().selection.stroke.color; - if let Some(item) = selected.and_then(|index| items.get(index)) { + // A hidden item has no handles, as the preview cannot grab it. + if let Some(item) = selected.and_then(|index| items.get(index)).filter(|item| item.visible) { view.selected(&painter, &item.rect, accent); } let grab = match &self.drag {