Skip to main content

dotloom_engine/
drag.rs

1//! Drag sessions: transient previews and a single history entry on release.
2//!
3//! * `begin_drag` captures the committed revision.
4//! * `drag_to` solves the affected component towards the pointer (strong
5//!   preferences, hard rules exact) and returns a *preview* scene delta. Nothing is
6//!   committed. When a position cannot satisfy hard rules, the last valid preview
7//!   stays on screen and the diagnostics are returned.
8//! * `end_drag(true)` commits the last valid preview as one history entry;
9//!   `end_drag(false)` (Escape, pointer cancel, focus loss) restores the committed
10//!   scene.
11//!
12//! When every component of a drag attempt is linear, the first accepted full solve
13//! seeds a [`LinearSession`] (Cassowary edit variables on the drag target); later
14//! pointer moves re-solve incrementally and fall back to a full solve for anything
15//! the session cannot decide (DL-SOLVE-4). Every preview, incremental or not, goes
16//! through the same independent check before it is shown.
17
18use std::collections::BTreeSet;
19
20use dotloom_constraints::{LinearSession, Problem, Solution, SolveJob, SolveOptions, Status};
21use dotloom_document::EntityId;
22use dotloom_geometry::{Affine, Point, TransformPolicy};
23use dotloom_scene::SceneDelta;
24use serde::{Deserialize, Serialize};
25
26use crate::{
27    Command, DocView, Engine, Overlay,
28    build::RuleOrigin,
29    command::{Applier, ApplyNotes},
30    engine::CommitReport,
31    error::{DiagnosticReport, EngineError},
32    eval::{Ctx, evaluate},
33    solve,
34    view::OverlayData,
35};
36
37/// What is dragged.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
40pub enum DragSpec {
41    /// Move entities by the pointer delta.
42    Move {
43        /// Entities.
44        ids: Vec<EntityId>,
45        /// Grab point (model).
46        from: Point,
47    },
48    /// Drag one anchor (endpoint, corner, wall end, ...) to the pointer.
49    Anchor {
50        /// Entity.
51        entity: EntityId,
52        /// Anchor name.
53        anchor: String,
54    },
55}
56
57/// Result of a drag update.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase")]
60pub struct DragPreview {
61    /// Whether this position satisfies every hard rule.
62    pub accepted: bool,
63    /// Solver status.
64    pub status: Status,
65    /// Diagnostics when not accepted.
66    pub diagnostics: Vec<DiagnosticReport>,
67    /// Whether the preview was solved incrementally by a linear session.
68    #[serde(default)]
69    pub incremental: bool,
70}
71
72/// Incremental solver state for one drag attempt kind.
73#[derive(Debug, Default)]
74enum SessionSlot {
75    /// No session yet (built after the next accepted full solve).
76    #[default]
77    Untried,
78    /// A live session.
79    Active(Box<LinearSession>),
80    /// The attempt has a nonlinear component: always solve fully.
81    Unavailable,
82}
83
84/// Solve one attempt, incrementally when a session can answer.
85fn solve_attempt(
86    slot: Option<&mut SessionSlot>,
87    problem: &Problem,
88    plan: &solve::Plan,
89    opts: SolveOptions,
90) -> (Solution, bool) {
91    let Some(slot) = slot else {
92        return (SolveJob::new(problem.clone(), opts).into_solution(), false);
93    };
94    if let SessionSlot::Active(session) = slot {
95        if let Some(sol) = session.resolve(problem) {
96            return (sol, true);
97        }
98        if session.is_broken() {
99            *slot = SessionSlot::Untried;
100        }
101    }
102    let sol = SolveJob::new(problem.clone(), opts).into_solution();
103    // Seed a session only from an accepted solve: then a failed build means a
104    // nonlinear component, not a position where hard rules happen to conflict.
105    if matches!(slot, SessionSlot::Untried) && sol.accepted() {
106        let moving: Vec<usize> =
107            plan.origins.iter().enumerate().filter(|(_, o)| matches!(o, RuleOrigin::Drag)).map(|(i, _)| i).collect();
108        *slot = LinearSession::new(problem, &moving, opts)
109            .map_or(SessionSlot::Unavailable, |s| SessionSlot::Active(Box::new(s)));
110    }
111    (sol, false)
112}
113
114/// An active drag.
115#[derive(Debug)]
116pub(crate) struct DragSession {
117    spec: DragSpec,
118    base_revision: u64,
119    last: Option<(OverlayData, ApplyNotes)>,
120    shown: BTreeSet<EntityId>,
121    sessions: [SessionSlot; 3],
122}
123
124/// Solver options for interactive previews.
125fn preview_options(base: SolveOptions) -> SolveOptions {
126    SolveOptions { analyze: false, max_iterations: base.max_iterations.min(40), ..base }
127}
128
129impl Engine {
130    /// Start dragging.
131    pub fn begin_drag(&mut self, spec: DragSpec) -> Result<(), EngineError> {
132        if self.pending.is_some() || self.drag.is_some() {
133            return Err(EngineError::Busy { reason: "an interaction is in progress".into() });
134        }
135        let ids: Vec<EntityId> = match &spec {
136            DragSpec::Move { ids, .. } => ids.clone(),
137            DragSpec::Anchor { entity, anchor } => {
138                let ctx = Ctx { view: &self.doc, registry: &self.registry };
139                if evaluate(ctx, *entity).anchor(anchor).is_none() {
140                    return Err(EngineError::Invalid { message: format!("{entity} has no anchor `{anchor}`") });
141                }
142                vec![*entity]
143            }
144        };
145        if ids.is_empty() {
146            return Err(EngineError::Invalid { message: "nothing to drag".into() });
147        }
148        for id in &ids {
149            let e =
150                self.doc.entity(*id).ok_or_else(|| EngineError::Invalid { message: format!("{id} does not exist") })?;
151            if e.locked || self.doc.layer(e.layer).is_some_and(|l| l.locked) {
152                return Err(EngineError::Command {
153                    error: crate::CommandError::ReadOnly { what: id.to_string(), reason: "locked".into() },
154                });
155            }
156        }
157        self.drag = Some(DragSession {
158            spec,
159            base_revision: self.revision,
160            last: None,
161            shown: BTreeSet::new(),
162            sessions: Default::default(),
163        });
164        Ok(())
165    }
166
167    /// Whether a drag is active.
168    #[must_use]
169    pub fn is_dragging(&self) -> bool {
170        self.drag.is_some()
171    }
172
173    /// Update the drag to a pointer position. Returns the preview status and a
174    /// preview scene delta (empty when the position is rejected).
175    pub fn drag_to(&mut self, to: Point) -> Result<(DragPreview, SceneDelta), EngineError> {
176        let Some(session) = self.drag.as_ref() else {
177            return Err(EngineError::NotActive { what: "drag".into() });
178        };
179        if session.base_revision != self.revision || !to.is_finite() {
180            return Err(EngineError::Stale { expected: session.base_revision, actual: self.revision });
181        }
182        let spec = session.spec.clone();
183        let mut ov = Overlay::new(&self.doc);
184        let mut notes = ApplyNotes::default();
185        let anchor_target = match &spec {
186            DragSpec::Move { ids, from } => {
187                let mut ap = Applier {
188                    ov: &mut ov,
189                    registry: &self.registry,
190                    reserved: &self.reserved,
191                    notes: ApplyNotes::default(),
192                };
193                ap.apply(Command::Transform {
194                    ids: ids.clone(),
195                    transform: Affine::translate(to - *from),
196                    policy: TransformPolicy::Strict,
197                })?;
198                notes = ap.notes;
199                None
200            }
201            DragSpec::Anchor { entity, anchor } => {
202                notes.touched.insert(*entity);
203                Some((*entity, anchor.clone(), to))
204            }
205        };
206        let opts = preview_options(self.options.solve);
207        let target = anchor_target.as_ref().map(|(e, a, p)| (*e, a.as_str(), *p));
208        // 1. Pinned, exact target: the dragged anchor lands on the pointer while the
209        //    rest of the dragged entity and `high`-stay properties keep their values.
210        // 2. Unpinned, exact target: everything may adapt (by stay priority).
211        // 3. Relaxed: the target becomes a preference — nearest feasible position.
212        let mut chosen = None;
213        let attempts: &[(bool, bool)] = if target.is_some() {
214            &[(true, true), (false, true), (false, false)]
215        } else {
216            &[(true, false), (false, false)]
217        };
218        for (i, &(pin, hard)) in attempts.iter().enumerate() {
219            let attempt = solve::Attempt { target, pin, hard_target: hard };
220            let Some((problem, plan)) = solve::plan_with(&ov, &self.deps, &self.registry, &notes, true, attempt) else {
221                break;
222            };
223            let slot = self.drag.as_mut().and_then(|s| s.sessions.get_mut(i));
224            let (sol, incremental) = solve_attempt(slot, &problem, &plan, opts);
225            let good = sol.accepted() && solve::targets_met(&problem, &plan, &sol);
226            let last = i + 1 == attempts.len();
227            if good || last {
228                if sol.accepted() || chosen.is_none() {
229                    chosen = Some((sol, problem, plan, incremental));
230                }
231                break;
232            }
233            if sol.accepted() && chosen.is_none() {
234                chosen = Some((sol, problem, plan, incremental));
235            }
236        }
237        let incremental = chosen.as_ref().is_some_and(|c| c.3);
238        let (accepted, status, diagnostics) = match chosen {
239            None => (true, Status::Solved, Vec::new()),
240            Some((sol, problem, plan, _)) => {
241                match solve::finish(&mut ov, &self.registry, &problem, &plan, &sol, &mut notes) {
242                    Ok(()) => (true, sol.status, Vec::new()),
243                    Err(EngineError::Solve { failure }) => (false, failure.status, failure.diagnostics),
244                    Err(EngineError::Validation { .. } | EngineError::Invalid { .. }) => {
245                        (false, sol.status, Vec::new())
246                    }
247                    Err(other) => return Err(other),
248                }
249            }
250        };
251        let mut delta = SceneDelta { revision: self.revision, preview: true, ..SceneDelta::default() };
252        if accepted {
253            // Preview items for changed entities and everything that depends on them.
254            let changed: Vec<EntityId> = ov.entities.keys().copied().collect();
255            let affected = self.dependents_closure(&changed);
256            let ctx = Ctx { view: &ov, registry: &self.registry };
257            let evs: Vec<(EntityId, crate::eval::Evaluated, Option<dotloom_document::Entity>)> =
258                affected.iter().map(|id| (*id, evaluate(ctx, *id), ov.entity(*id).cloned())).collect();
259            let data = ov.into_data();
260            for (id, ev, ent) in evs {
261                if let Some(item) = self.scene_item(id, Some(&ev), ent.as_ref()) {
262                    delta.upserts.push(item);
263                }
264            }
265            if let Some(s) = self.drag.as_mut() {
266                s.shown.extend(affected.iter().copied());
267                s.last = Some((data, notes));
268            }
269        }
270        Ok((DragPreview { accepted, status, diagnostics, incremental }, delta))
271    }
272
273    /// Finish the drag: commit the last valid preview as one history entry, or
274    /// cancel and restore the committed scene.
275    pub fn end_drag(&mut self, commit: bool) -> Result<Option<CommitReport>, EngineError> {
276        let Some(session) = self.drag.take() else {
277            return Err(EngineError::NotActive { what: "drag".into() });
278        };
279        self.scene.preview.extend(session.shown.iter().copied());
280        if !commit || session.base_revision != self.revision {
281            return Ok(None);
282        }
283        let Some((data, notes)) = session.last else { return Ok(None) };
284        let label = match session.spec {
285            DragSpec::Move { .. } => "Move",
286            DragSpec::Anchor { .. } => "Drag point",
287        };
288        // Re-solve with full analysis from the preview state to report DOF, then commit.
289        let id = self.start_pending(data, notes, label.into(), "drag", true)?;
290        loop {
291            match self.step_pending(u32::MAX)? {
292                crate::engine::PendingState::Done(r) => return (*r).map(Some),
293                crate::engine::PendingState::Running { id: pid } if pid == id => {}
294                crate::engine::PendingState::Running { .. } => {
295                    return Err(EngineError::Busy { reason: "unexpected pending request".into() });
296                }
297            }
298        }
299    }
300}