mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 20:12:49 +00:00
Fixes #1745 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
389 lines
16 KiB
Rust
389 lines
16 KiB
Rust
use super::{
|
|
Arc, BatchScope, BatchStep, DocState, Document, EngineError, HistoryEntry, Interaction, NodeId, PREVIEW_READ_ONLY, Result, Session, UiRequest,
|
|
Value, cmd, figcraft_collab, json, normalize, textpreview,
|
|
};
|
|
|
|
struct GroupDocCheckpoint {
|
|
uid: u64,
|
|
doc: Arc<Document>,
|
|
selection: Vec<NodeId>,
|
|
page: NodeId,
|
|
undo_len: usize,
|
|
/// An undo inside pops undo steps; a failed group has to bring them back.
|
|
undo: Vec<HistoryEntry>,
|
|
redo: Vec<HistoryEntry>,
|
|
limit: usize,
|
|
group_step: Option<usize>,
|
|
}
|
|
|
|
struct GroupCheckpoint {
|
|
docs: Vec<GroupDocCheckpoint>,
|
|
active: usize,
|
|
journal_len: usize,
|
|
/// Live events buffered before the group: a failed one drops only its own.
|
|
pending_events: usize,
|
|
last_command: Option<(String, Value)>,
|
|
}
|
|
|
|
impl Session {
|
|
/// How many undo and redo steps the active document has (the user's own in a live session).
|
|
pub fn undo_depth(&self) -> (usize, usize) {
|
|
match self.ds() {
|
|
Ok(ds) if self.live_doc(ds.uid) => self.live().client().map_or((0, 0), figcraft_collab::Client::history_len),
|
|
Ok(ds) => (ds.history.undo.len(), ds.history.redo.len()),
|
|
Err(_) => (0, 0),
|
|
}
|
|
}
|
|
|
|
/// The labels of the active document's last `n` undo steps, the next one first.
|
|
pub fn undo_labels(&self, n: usize) -> Vec<String> {
|
|
match self.ds() {
|
|
Ok(ds) if self.live_doc(ds.uid) => {
|
|
self.live().client().map(|c| c.undo_labels().rev().take(n).map(str::to_string).collect()).unwrap_or_default()
|
|
}
|
|
Ok(ds) => ds.history.undo.iter().rev().take(n).map(|h| h.label.clone()).collect(),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Start a gesture: later [`preview`](Self::preview) calls re-apply edits to this snapshot.
|
|
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
|
|
textpreview::end(self);
|
|
if let Some(why) = self.active_read_only() {
|
|
return Err(EngineError::Disabled(label.to_string(), why.into()));
|
|
}
|
|
let ds = self.ds_mut()?;
|
|
if ds.preview.is_some() {
|
|
return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into()));
|
|
}
|
|
if ds.interaction.is_none() {
|
|
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone(), moved_by: None });
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Replace the document with `f(snapshot)` for a live preview during a gesture.
|
|
pub fn preview(&mut self, f: impl FnOnce(&mut Document, &mut Vec<NodeId>) -> Result<()>) -> Result<()> {
|
|
let ds = self.ds_mut()?;
|
|
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
|
|
let mut doc = (*it.doc).clone();
|
|
let mut sel = it.selection.clone();
|
|
// What the gesture changes on the selected layers is an explicit edit.
|
|
let before = cmd::styles::bound_before(&doc, &it.selection);
|
|
let res = f(&mut doc, &mut sel);
|
|
if res.is_ok() {
|
|
cmd::styles::detach_edited(&mut doc, &before);
|
|
normalize(&mut doc, &it.doc);
|
|
// The same preview again (a handle held still) keeps the document: a new one would
|
|
// re-render the canvas for nothing.
|
|
if doc == *ds.doc && sel == ds.selection {
|
|
return res;
|
|
}
|
|
ds.doc = Arc::new(doc);
|
|
ds.selection = sel;
|
|
ds.clean_selection();
|
|
ds.revision += 1;
|
|
}
|
|
res
|
|
}
|
|
|
|
/// Finish a gesture as one undo step (no step if nothing changed).
|
|
pub fn commit_interaction(&mut self) -> Result<()> {
|
|
let ds = self.ds_mut()?;
|
|
let Some(it) = ds.interaction.take() else { return Ok(()) };
|
|
let (uid, page) = (ds.uid, ds.page);
|
|
ds.revision += 1;
|
|
if !Arc::ptr_eq(&it.doc, &ds.doc) && *it.doc != *ds.doc {
|
|
self.record(uid, Some(&it.label), it.doc, it.selection, page);
|
|
} else {
|
|
ds.doc = it.doc;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Abandon a gesture, restoring the document.
|
|
pub fn cancel_interaction(&mut self) -> Result<()> {
|
|
let ds = self.ds_mut()?;
|
|
if let Some(it) = ds.interaction.take() {
|
|
ds.doc = it.doc;
|
|
ds.selection = it.selection;
|
|
ds.revision += 1;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn undo(&mut self) -> Result<bool> {
|
|
textpreview::end(self);
|
|
// While the pen draws, undo works on the anchors of its path (#1745).
|
|
if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_undo()? {
|
|
return Ok(true);
|
|
}
|
|
let live = self.ds().is_ok_and(|d| self.live_doc(d.uid));
|
|
let ds = self.ds_mut()?;
|
|
if ds.preview.is_some() {
|
|
return Err(EngineError::Disabled("edit.undo".into(), PREVIEW_READ_ONLY.into()));
|
|
}
|
|
if let Some(it) = ds.interaction.take()
|
|
&& live
|
|
{
|
|
// The gesture in progress is dropped (it was never synced).
|
|
ds.doc = it.doc;
|
|
}
|
|
ds.text_edit = None;
|
|
if live {
|
|
return Ok(self.live_travel(true));
|
|
}
|
|
let Some(e) = ds.history.undo.pop() else { return Ok(false) };
|
|
let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
|
|
ds.doc = e.doc;
|
|
cmd::facilitate::keep(&redo.doc, &mut ds.doc);
|
|
ds.history.redo.push(redo);
|
|
ds.selection = e.selection;
|
|
if ds.doc.pages.contains(&e.page) {
|
|
ds.page = e.page;
|
|
}
|
|
ds.clean_selection();
|
|
ds.revision += 1;
|
|
let (uid, doc) = (ds.uid, ds.doc.clone());
|
|
if self.clipboard.source_doc == uid {
|
|
self.clipboard.copied_components.retain(|id| {
|
|
doc.node(*id)
|
|
.is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. }))
|
|
});
|
|
}
|
|
Ok(true)
|
|
}
|
|
|
|
pub fn redo(&mut self) -> Result<bool> {
|
|
textpreview::end(self);
|
|
// While the pen draws, redo works on the anchors of its path (#1745).
|
|
if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_redo()? {
|
|
return Ok(true);
|
|
}
|
|
let live = self.ds().is_ok_and(|d| self.live_doc(d.uid));
|
|
let ds = self.ds_mut()?;
|
|
if ds.preview.is_some() {
|
|
return Err(EngineError::Disabled("edit.redo".into(), PREVIEW_READ_ONLY.into()));
|
|
}
|
|
if let Some(it) = ds.interaction.take()
|
|
&& live
|
|
{
|
|
ds.doc = it.doc;
|
|
}
|
|
ds.text_edit = None;
|
|
if live {
|
|
return Ok(self.live_travel(false));
|
|
}
|
|
let Some(e) = ds.history.redo.pop() else { return Ok(false) };
|
|
let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
|
|
ds.doc = e.doc;
|
|
cmd::facilitate::keep(&undo.doc, &mut ds.doc);
|
|
ds.history.undo.push(undo);
|
|
ds.selection = e.selection;
|
|
if ds.doc.pages.contains(&e.page) {
|
|
ds.page = e.page;
|
|
}
|
|
ds.clean_selection();
|
|
ds.revision += 1;
|
|
Ok(true)
|
|
}
|
|
|
|
/// Queue a UI request (dialogs etc.).
|
|
pub fn request_ui(&mut self, kind: &str, data: Value) {
|
|
self.ui_requests.push(UiRequest { kind: kind.to_string(), data });
|
|
}
|
|
|
|
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
|
|
/// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of
|
|
/// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`)
|
|
/// into that result. So a step can target a layer that an earlier step of the same batch
|
|
/// created. Any object with a `$step` or `$ref` key in a step's params is such a reference,
|
|
/// except inside a nested `command.batch` step: its references are to its own steps.
|
|
pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result<Vec<Value>> {
|
|
let mut scope = BatchScope::new(steps)?;
|
|
self.grouped(label, |s| {
|
|
for step in steps {
|
|
s.batch_step(&mut scope, &step.command, &step.params)?;
|
|
}
|
|
Ok(scope.into_results())
|
|
})
|
|
}
|
|
|
|
/// Run every step, rolling back failed steps and returning each outcome, still as one undo step.
|
|
pub(crate) fn batch_continue(&mut self, label: &str, steps: &[BatchStep]) -> Result<Vec<Value>> {
|
|
let mut scope = BatchScope::new(steps)?;
|
|
self.grouped(label, |s| {
|
|
let mut outcomes = Vec::with_capacity(steps.len());
|
|
for (i, step) in steps.iter().enumerate() {
|
|
let outcome = match s.checkpointed(None, |s| s.execute_batch_step(&scope, &step.command, &step.params)) {
|
|
Ok(v) => {
|
|
scope.push(v.clone());
|
|
json!({"step": i, "command": step.command, "ok": true, "result": v})
|
|
}
|
|
Err(EngineError::Waiting) => return Err(EngineError::Waiting),
|
|
Err(e) => {
|
|
scope.push_failed();
|
|
json!({"step": i, "command": step.command, "ok": false, "error": e.to_string()})
|
|
}
|
|
};
|
|
outcomes.push(outcome);
|
|
}
|
|
Ok(outcomes)
|
|
})
|
|
}
|
|
|
|
/// The next step of a batch: the references in `params` resolve against `scope` (the steps
|
|
/// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list
|
|
/// of these as one undo step; a caller with its own list — the CLI's script runner — uses this
|
|
/// so its steps resolve references the same way, inside its own [`Session::grouped`].
|
|
pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> {
|
|
let v = self.execute_batch_step(scope, cmd, params)?;
|
|
scope.push(v);
|
|
Ok(())
|
|
}
|
|
|
|
fn execute_batch_step(&mut self, scope: &BatchScope, cmd: &str, params: &Value) -> Result<Value> {
|
|
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
|
|
// Say which step failed; a nested batch names its own steps.
|
|
self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })
|
|
}
|
|
|
|
/// Take the tabs `pred` matches out of `docs`; inside a group they are kept so that a failed
|
|
/// group can reopen them.
|
|
pub(crate) fn remove_tabs(&mut self, mut pred: impl FnMut(&DocState) -> bool) {
|
|
let (gone, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.docs).into_iter().partition(|d| pred(d));
|
|
self.docs = keep;
|
|
if self.group_depth > 0 {
|
|
self.closed_in_group.extend(gone);
|
|
}
|
|
}
|
|
|
|
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
|
|
/// own, and when it fails or panics the documents stay at their group-start state (a panic
|
|
/// then carries on unwinding).
|
|
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
|
self.ds()?;
|
|
self.checkpointed(Some(label), f)
|
|
}
|
|
|
|
/// Without a label, only roll back failures: leave successful steps' history to the outer group.
|
|
/// A checkpoint also works between closing the last tab and opening another one.
|
|
fn checkpointed<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
|
let before = self.group_checkpoint(label.is_some());
|
|
// A panic in `f` ends the group like a failure, then goes on to the caller's guard (#1085);
|
|
// the stand-in error only drives the rollback and is never returned.
|
|
let (result, panic) = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
|
|
Ok(result) => (result, None),
|
|
Err(payload) => (Err(EngineError::Other("the group panicked".into())), Some(payload)),
|
|
};
|
|
self.group_depth -= 1;
|
|
let at_root = self.group_depth == 0;
|
|
for ds in &before.docs {
|
|
self.live_hold(ds.uid, false);
|
|
}
|
|
|
|
if result.is_err() {
|
|
self.restore_group_checkpoint(before, label.is_none());
|
|
} else {
|
|
let mut changed = Vec::new();
|
|
for saved in &before.docs {
|
|
let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) else { continue };
|
|
ds.history.limit = saved.limit;
|
|
if label.is_some() {
|
|
ds.history.group_step = saved.group_step;
|
|
if !Arc::ptr_eq(&saved.doc, &ds.doc) && ds.interaction.is_none() {
|
|
changed.push((saved.uid, saved.doc.clone(), saved.selection.clone(), saved.page));
|
|
}
|
|
ds.history.undo.truncate(saved.undo_len);
|
|
}
|
|
}
|
|
if let Some(label) = label {
|
|
for (uid, doc, selection, page) in changed {
|
|
self.record(uid, Some(label), doc, selection, page);
|
|
}
|
|
}
|
|
}
|
|
if at_root {
|
|
// Tabs opened inside the group have no checkpoint to restore theirs from.
|
|
for ds in &mut self.docs {
|
|
ds.history.group_step = None;
|
|
}
|
|
self.closed_in_group.clear();
|
|
self.flush_pending_events();
|
|
}
|
|
if let Some(payload) = panic {
|
|
std::panic::resume_unwind(payload);
|
|
}
|
|
result
|
|
}
|
|
|
|
/// `own_step`: the group becomes an undo step of its own (it has a label), so its edits don't
|
|
/// add to the step of a group around it.
|
|
fn group_checkpoint(&mut self, own_step: bool) -> GroupCheckpoint {
|
|
let docs = self
|
|
.docs
|
|
.iter()
|
|
.map(|ds| GroupDocCheckpoint {
|
|
uid: ds.uid,
|
|
doc: ds.doc.clone(),
|
|
selection: ds.selection.clone(),
|
|
page: ds.page,
|
|
undo_len: ds.history.undo.len(),
|
|
undo: ds.history.undo.clone(),
|
|
// An edit inside clears redo; a failed group has to bring it back.
|
|
redo: ds.history.redo.clone(),
|
|
limit: ds.history.limit,
|
|
group_step: ds.history.group_step,
|
|
})
|
|
.collect();
|
|
let before = GroupCheckpoint {
|
|
docs,
|
|
active: self.active,
|
|
journal_len: self.journal.len(),
|
|
pending_events: self.pending_events.len(),
|
|
last_command: self.last_command.clone(),
|
|
};
|
|
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
|
for ds in &mut self.docs {
|
|
ds.history.limit = usize::MAX;
|
|
if own_step {
|
|
ds.history.group_step = None;
|
|
}
|
|
}
|
|
for ds in &before.docs {
|
|
self.live_hold(ds.uid, true);
|
|
}
|
|
self.group_depth += 1;
|
|
before
|
|
}
|
|
|
|
fn restore_group_checkpoint(&mut self, before: GroupCheckpoint, restore_last_command: bool) {
|
|
// Tabs the group closed come back from the stash, and the group-start order returns.
|
|
let mut current = std::mem::take(&mut self.docs);
|
|
for saved in &before.docs {
|
|
let tab = match current.iter().position(|d| d.uid == saved.uid) {
|
|
Some(i) => Some(current.swap_remove(i)),
|
|
None => self.closed_in_group.iter().position(|d| d.uid == saved.uid).map(|i| self.closed_in_group.swap_remove(i)),
|
|
};
|
|
self.docs.extend(tab);
|
|
}
|
|
for saved in before.docs {
|
|
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) {
|
|
ds.history.redo = saved.redo;
|
|
ds.doc = saved.doc;
|
|
ds.selection = saved.selection;
|
|
ds.page = saved.page;
|
|
ds.history.undo = saved.undo;
|
|
ds.history.limit = saved.limit;
|
|
ds.history.group_step = saved.group_step;
|
|
}
|
|
}
|
|
self.active = before.active;
|
|
self.journal.truncate(before.journal_len);
|
|
self.pending_events.truncate(before.pending_events);
|
|
if restore_last_command {
|
|
self.last_command = before.last_command;
|
|
}
|
|
}
|
|
}
|