From d2e21a38bf4c5c71d01a6afa22caf0ee1137ef8d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:49:14 -0800 Subject: [PATCH] Actions: recording, step exclusions and set edits keep sets that Load Actions reads Load Actions refuses an action set file over 8 MiB, with more than 1,000 actions, or with an action of more than 10,000 steps. Several ways of changing a set could pass those limits, leaving a set that Save Actions refuses to write. - While an action records, a command (a batch and Find Objects that selects included) whose step would give the action more than 10,000 steps or its set's file more than 8 MiB does not run, and the recording goes on. A stop that playback reaches is left out then. A command whose step grows past a limit only as it runs keeps its edit; the status bar names the limit and Stop leaves the step out. Stop also leaves out any recorded step still past a limit. Steps recorded after an exclusion past an action's last step are included. - An exclusion that would make the set's file larger than 8 MiB changes no checkmark; one that keeps the size or shrinks it always works. - Begin Recording, New Action, Duplicate, renaming an action, Action Options, Set Options and New Set refuse a change that would give a set more than 1,000 actions or a file larger than 8 MiB. A change that leaves a set no further past a limit than it is now is allowed. - Load Actions refuses a set, .aia sets included, that Save Actions would write larger than 8 MiB (a file with less white space, or a set name that gets a number added). The limits are the existing ones from files.rs, and the errors reuse the existing messages. docs/mcp.md and the commands' params text describe the new errors. --- crates/engine/src/journal.rs | 40 + crates/ui-egui/src/app/commands.rs | 11 + .../ui-egui/src/dialogs/action_set_options.rs | 18 +- crates/ui-egui/src/menus/ui_commands.rs | 16 +- crates/ui-egui/src/panels/actions.rs | 864 ++++++++++++++++-- crates/ui-egui/src/panels/actions/files.rs | 58 +- crates/ui-egui/src/state.rs | 12 + docs/mcp.md | 33 +- docs/target-app-parity.md | 1 + 9 files changed, 955 insertions(+), 98 deletions(-) diff --git a/crates/engine/src/journal.rs b/crates/engine/src/journal.rs index 230bcfba0d..c9a2547b3c 100644 --- a/crates/engine/src/journal.rs +++ b/crates/engine/src/journal.rs @@ -25,6 +25,8 @@ pub struct Journal { bytes: usize, /// Keep every entry from this number on ([`Journal::hold`]). held: Option, + /// How many times kept entries were changed, dropped or renumbered ([`Journal::rewrites`]). + rewrites: u64, } /// Journals with the same entries (by number) are equal, whatever their history. @@ -66,14 +68,24 @@ impl Journal { self.entries.get(i.checked_sub(self.dropped)?) } pub fn get_mut(&mut self, i: usize) -> Option<&mut JournalEntry> { + self.rewrites += 1; self.entries.get_mut(i.checked_sub(self.dropped)?) } pub fn last(&self) -> Option<&JournalEntry> { self.entries.last() } pub fn last_mut(&mut self) -> Option<&mut JournalEntry> { + self.rewrites += 1; self.entries.last_mut() } + /// A number that grows whenever a kept entry may have changed, gone or been renumbered + /// ([`Journal::truncate`], [`Journal::remove`], [`Journal::replace`], [`Journal::get_mut`], + /// [`Journal::last_mut`]), and that [`Journal::push`] and letting old entries go leave as it + /// is. While it stays the same, the entries from a held number on are the ones counted before, + /// with the new ones after them. + pub fn rewrites(&self) -> u64 { + self.rewrites + } pub fn push(&mut self, e: JournalEntry) { let size = entry_bytes(&e); self.entries.push(e); @@ -83,6 +95,7 @@ impl Journal { } /// Drop the entries from number `len` on. pub fn truncate(&mut self, len: usize) { + self.rewrites += 1; let keep = len.saturating_sub(self.dropped); self.entries.truncate(keep); self.sizes.truncate(keep); @@ -92,6 +105,7 @@ impl Journal { /// Drop the entries numbered `numbers` (sorted, a number may repeat; the entries of canceled undo /// groups). The entries after them are numbered down to close the gaps. pub fn remove(&mut self, numbers: &[usize]) { + self.rewrites += 1; let dropped = self.dropped; let gone = |i: usize| numbers.binary_search(&(dropped + i)).is_ok(); let mut i = 0; @@ -115,6 +129,7 @@ impl Journal { } /// Put `e` in place of entry number `i`, if kept. pub fn replace(&mut self, i: usize, e: JournalEntry) { + self.rewrites += 1; let Some(k) = i.checked_sub(self.dropped) else { return }; if let (Some(old), Some(size)) = (self.entries.get_mut(k), self.sizes.get_mut(k)) { *size = entry_bytes(&e); @@ -253,4 +268,29 @@ mod tests { j.replace(29_000, big(0)); assert_eq!(j.bytes, j.iter().map(entry_bytes).sum::()); } + + #[test] + fn rewrites_count_changes_to_kept_entries_but_not_new_ones() { + let mut j = Journal::default(); + j.hold(Some(0)); + for i in 0..100 { + j.push(("object.move".into(), json!({"dx": i}))); + } + assert_eq!(j.rewrites(), 0, "pushes add entries after the counted ones"); + let changes: [fn(&mut Journal); 5] = [ + |j| j.truncate(90), + |j| j.remove(&[3]), + |j| j.replace(4, ("object.group".into(), json!({}))), + |j| { + let _ = j.get_mut(5); + }, + |j| { + let _ = j.last_mut(); + }, + ]; + for (i, change) in changes.into_iter().enumerate() { + change(&mut j); + assert_eq!(j.rewrites(), i as u64 + 1); + } + } } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 16e8a14cc1..c418f1950c 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -150,6 +150,7 @@ impl VectorcraftApp { io::remember_active_artboard(self); let artboards = self.artboard_tool_state(); if let Some(r) = menus::run_ui_command(self, id, ¶ms) { + self.ui.recording_base = None; self.follow_artboard_tool(artboards); return r; } @@ -221,6 +222,15 @@ impl VectorcraftApp { if fileio::SaveMode::of(id).is_some() { io::remember_view(self); } + // While an action records, a command that the recording would keep as a step runs only + // when the step fits in the action. + let recorded = match panels::actions::before_recorded_command(self, id, ¶ms) { + Ok(recorded) => recorded, + Err(e) => { + self.ui.status = e.clone(); + return Err(e); + } + }; // A command that changes the edited text is an application edit (undo, a panel or menu // edit, a control request). Capture the bounded committed context around it so `Other` // dominates the frame even if an IME receipt follows. Selection/view commands that leave @@ -230,6 +240,7 @@ impl VectorcraftApp { let ime_before = crate::ime::stamp(&mut self.session, ime_view); let cursor_settings = (self.session.caps_lock, self.session.prefs.use_precise_cursors); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); + let r = panels::actions::after_recorded_command(self, recorded, r); if r.is_ok() && cursor_settings != (self.session.caps_lock, self.session.prefs.use_precise_cursors) { self.canvas.hover_cursor = None; } diff --git a/crates/ui-egui/src/dialogs/action_set_options.rs b/crates/ui-egui/src/dialogs/action_set_options.rs index f4c2ecdedf..357f6e6840 100644 --- a/crates/ui-egui/src/dialogs/action_set_options.rs +++ b/crates/ui-egui/src/dialogs/action_set_options.rs @@ -6,8 +6,9 @@ //! also holds the action's function key and color, as New Action does (`key`, `cmd`, `shift`, //! `color`, see [`super::action_new`]). OK adds the set and makes it the current //! one, renames the set, or changes the action's name, function key and color -//! (`ui.action.options`). An invalid `set`, `action` or function key is an error. OK then changes no set or -//! action, and the dialog stays open. +//! (`ui.action.options`). An invalid `set`, `action` or function key is an error, and so is a name +//! that would give the set a file that Load Actions… refuses (larger than 8 MiB). OK then changes no +//! set or action, and the dialog stays open. use serde_json::{Value, json}; @@ -83,10 +84,17 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { p["name"] = json!(name); app.run(crate::panels::actions::ACTION_OPTIONS, p)?; } else if let Some(set) = d.fields.get("set") { - let s = set.as_u64().and_then(|i| usize::try_from(i).ok()).and_then(|i| app.ui.action_sets.get_mut(i)).ok_or("no such action set")?; - s.name = name; + let i = set.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < app.ui.action_sets.len()).ok_or("no such action set")?; + let mut edited = app.ui.action_sets[i].clone(); + edited.name = name; + crate::panels::actions::edit_keeps_set_loadable(app, Some(i), &edited)?; + app.ui.action_sets[i] = edited; + // A recording into the set measures its file again. + app.ui.recording_base = None; } else { - app.ui.action_sets.push(crate::panels::actions::ActionSet::new(name, vec![])); + let set = crate::panels::actions::ActionSet::new(name, vec![]); + crate::panels::actions::edit_keeps_set_loadable(app, None, &set)?; + app.ui.action_sets.push(set); app.ui.action_set = Some(app.ui.action_sets.len() - 1); } app.ui.dialog = None; diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index b05da18e12..68c25009a5 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1372,25 +1372,25 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::RENAME_ACTION, "Rename Action", "", - "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, or an empty name, is an error and renames nothing → {name}", + "{set: index, action: index, name} rename the Actions panel's action at action in set (0 is the top set) to name, with spaces trimmed from both ends; its steps, function key and color stay. An index past the last set or action, an empty name, or a name that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a name that leaves the file as large or makes it smaller is never refused) is an error and renames nothing → {name}", ), ( crate::panels::actions::INCLUDE_STEPS, "Include Action Steps", "", - "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing → {set, action?, step?, included}", + "{set: index, action?: index, step?: index, included: bool, others?: bool} include or exclude the playback of every step in a set, every step in one action, or one step. With a step and others: true, leave that step unchanged and apply included to every other step in its action (Alt/Option-click). Invalid indexes change nothing. An exclusion that would give the set's action set file more than 8 MiB, counting the steps the recording in progress adds to the set, is an error and changes nothing; a change that leaves the file as large or makes it smaller is never refused → {set, action?, step?, included}", ), ( crate::panels::actions::NEW_ACTION, "New Action", "", - "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, or a key or color that is not one of these, it is an error and nothing is added → {set, action}", + "{set: index, name, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} add an action named name (spaces trimmed from both ends) after the last action of the Actions panel's set at set (0 is the top set) and start recording into it; Stop Playing/Recording (ui.action.stop) ends the recording. key 1 to 15 is the function key F1 to F15 that plays the action (0 or null: none), with cmd (Ctrl, Command on macOS) and shift; color is its Button Mode color (null or \"\": none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. The set becomes current and the new action is selected. The panel's Create New Action button and menu's New Action… (dialog `actionNew` with set, name, key, cmd, shift and color) run this on Record. While an action is recording, and for an index past the last set, an empty name, a key or color that is not one of these, or an action that would give the set more than 1,000 actions or an action set file larger than 8 MiB, it is an error, nothing is added and no recording starts → {set, action}", ), ( crate::panels::actions::ACTION_OPTIONS, "Action Options", "", - "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, or a key or color that is not one of these is an error and changes nothing → {name, key, color}", + "{set: index, action: index, name?, key?: 0-15, cmd?, shift?, color?: red|yellow|green|blue|violet} change the Actions panel's action at action in set (0 is the top set); its steps stay. name renames it (spaces trimmed from both ends), key 1 to 15 with cmd (Ctrl, Command on macOS) and shift sets the function key F1 to F15 that plays it (0 or null: none), and color sets its Button Mode color (null or \"\": none); a param left out keeps what the action has, and cmd or shift alone changes that modifier of its key. Action function keys take priority over command shortcuts and preserve the command's binding. The panel menu's Action Options… (dialog `actionSetOptions` with set, action, name, key, cmd, shift and color) runs this for the selected action. An index past the last set or action, an empty name, a key or color that is not one of these, or a change that would give the set's action set file more than 8 MiB (counting the steps the recording in progress adds to it; a change that leaves the file as large or makes it smaller is never refused) is an error and changes nothing → {name, key, color}", ), ( crate::panels::actions::PLAY_ACTION, @@ -1408,13 +1408,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::START_RECORDING, "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set or an existing recording is an error and changes nothing → {set, name}", + "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on → {set, name}", ), ( crate::panels::actions::STOP_RECORDING, "Stop Playing/Recording", "", - "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. With nothing recording or playing it changes nothing → {stopped}", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( crate::panels::actions::INSERT_STOP, @@ -1456,7 +1456,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::LOAD_SET, "Load Actions…", "", - "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused; the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", + "{path? | data?: the file's text} read an action set file (.vcactions) at path, else a picked one, and add its set after the Actions panel's sets; a set named like one in the panel gets the next free number (Print 2), and the other sets stay. Files of another version, over 8 MiB, with more than 1,000 actions or with an action of more than 10,000 steps are refused, and so is any set, .aia ones too, that Save Actions… would write larger than 8 MiB (a file with less white space, or a name that gets a number); the panel menu's Load Actions… (disabled where there is no open dialog, the web) → {set: index, name, actions: n}. An action set another app saved as text (.aia, starting /version; read with actionSet.read) comes in with its actions. A step with a label and no recorded values comes in when exactly one menu command has that label (in English or the interface language, any case, no final …), and that command is directly in a top menu, can be recorded in actions, is what its menu bar item runs with no values and opens no dialog (never a submenu's item, a file or document command, Paste, Undo, Redo or an add-on's command), with no params and excluded from playback when it is off in the file; the other steps, every step with no label and every step recorded with values (a recorded menu item too) among them, are left out → {set, name, actions, skipped: [{action, step: its label, else its name}] (the first 1,000), leftOut: n}, and the status bar says how many were left out; a set with no name takes the file's name, else Actions; a file actionSet.read marks truncated is refused, and text that starts /version and isn't an action set gives \"not an action set file\"", ), ( crate::panels::actions::DELETE_SETS, @@ -1468,7 +1468,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::DUPLICATE, "Duplicate Action or Action Set", "", - "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action is an error → {set, action?}", + "{set?: index, action?: index} copy the Actions panel's action at action in set (0 is the top set) to right after it in the same set; without action, copy set with all its actions to right after it (default: the current set). The copy is named after the original with \" copy\" added, a copied set gets an id of its own, and the copy is selected. The panel menu's Duplicate runs this for the selected action, else the current set. An index past the last set or action, or a copy that would give its set more than 1,000 actions or an action set file larger than 8 MiB, is an error and copies nothing → {set, action?}", ), ("file.showPackage", "Show Package", "", "{folder} show a package folder (file.package's folder) in the file manager → {folder}"), ( diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index de6b36bc6e..d0c6e05448 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -54,13 +54,6 @@ impl Action { (0..self.steps.len()).all(|i| !self.excluded_steps.contains(&i)) } - fn set_steps_included(&mut self, included: bool) { - self.excluded_steps.clear(); - if !included { - self.excluded_steps.extend(0..self.steps.len()); - } - } - /// Puts `step` at index `at`; the steps from there on keep whether they are excluded. An /// exclusion past the last step, which saved preferences can hold, goes. fn insert_step(&mut self, at: usize, step: (String, Value)) { @@ -373,7 +366,8 @@ struct AtStop { } /// Takes the stop at the front of the playback's steps and waits there. A recording in progress -/// records the stop too. +/// records the stop too, unless the stop does not fit in its action ([`recording_room`]): then the +/// status bar shows the reason. fn hold_at_stop(app: &mut VectorcraftApp) { let Some(playing) = app.ui.action_playing.as_mut() else { return }; let Some(step) = playing.steps.pop_front() else { return }; @@ -383,7 +377,10 @@ fn hold_at_stop(app: &mut VectorcraftApp) { playing.stop = Some(AtStop { message, allow_continue, shown: false }); playing.hold = false; if app.ui.recording.is_some() { - app.session.journal.push((STOP_STEP.to_string(), params)); + match recording_room(app, STOP_STEP, ¶ms) { + Ok(()) => app.session.journal.push((STOP_STEP.to_string(), params)), + Err(e) => app.status(e), + } } show_stop_message(app); } @@ -621,7 +618,10 @@ pub const INCLUDE_STEPS: &str = "ui.action.stepsIncluded"; /// `{set, action?, step?, included, others?}` includes or excludes all steps of a set, all steps /// of one action, or one step. With `step` and `others: true`, leaves that step as it was and -/// applies `included` to the other steps of its action. Invalid indexes change nothing. +/// applies `included` to the other steps of its action. Invalid indexes change nothing. So does an +/// exclusion that would give the set a file larger than [`files::MAX_SET_FILE`], the most Load +/// Actions… reads, counting the steps that the recording in progress adds to the set; it is an +/// error. A change that leaves the file as large or makes it smaller is never refused. pub fn include_steps(app: &mut VectorcraftApp, p: &Value) -> Result { let index = |key: &str| p.get(key).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()); let set = index("set").ok_or("no such action set")?; @@ -636,34 +636,53 @@ pub fn include_steps(app: &mut VectorcraftApp, p: &Value) -> Result { - for action in &mut set_ref.actions { - action.set_steps_included(included); - } - } - (Some(ai), None) => set_ref.actions.get_mut(ai).ok_or("no such action")?.set_steps_included(included), - (Some(ai), Some(si)) => { - let action = set_ref.actions.get_mut(ai).ok_or("no such action")?; - if si >= action.steps.len() { - return Err("no such action step".into()); - } - if others { - let was_excluded = action.excluded_steps.contains(&si); - action.set_steps_included(included); - if was_excluded { - action.excluded_steps.insert(si); + let set_ref = app.ui.action_sets.get(set).ok_or("no such action set")?; + let actions = match action { + None => 0..set_ref.actions.len(), + Some(ai) if ai < set_ref.actions.len() => ai..ai + 1, + Some(_) => return Err("no such action".into()), + }; + if let (Some(ai), Some(si)) = (action, step) + && set_ref.actions.get(ai).is_none_or(|a| si >= a.steps.len()) + { + return Err("no such action step".into()); + } + // The exclusions each action gets, worked out before any of them changes. + let every = |a: &Action| if included { BTreeSet::new() } else { (0..a.steps.len()).collect() }; + let changes: Vec<(usize, BTreeSet)> = actions + .map(|ai| { + let a = &set_ref.actions[ai]; + let mut excluded = match step { + Some(_) if !others => a.excluded_steps.clone(), + _ => every(a), + }; + if let Some(si) = step { + let keep_excluded = if others { a.excluded_steps.contains(&si) } else { !included }; + if keep_excluded { + excluded.insert(si); } else { - action.excluded_steps.remove(&si); + excluded.remove(&si); } - } else if included { - action.excluded_steps.remove(&si); - } else { - action.excluded_steps.insert(si); } + (ai, excluded) + }) + .collect(); + // Each excluded step takes [`files::EXCLUDED_BYTES`] in the file; only exclusions past the + // ones the set has now can make it too large. + let (mut now, mut then) = (0, 0); + for (ai, excluded) in &changes { + let a = &set_ref.actions[*ai]; + now += a.excluded_steps.range(..a.steps.len()).count(); + then += excluded.range(..a.steps.len()).count(); + } + if then > now && set_file_len(app, set) + (then - now) * files::EXCLUDED_BYTES > files::MAX_SET_FILE { + return Err("the action set would be larger than 8 MiB".into()); + } + let set_ref = app.ui.action_sets.get_mut(set).ok_or("no such action set")?; + for (ai, excluded) in changes { + if let Some(a) = set_ref.actions.get_mut(ai) { + a.excluded_steps = excluded; } - (None, Some(_)) => return Err("no such action step".into()), } Ok(json!({"set": set, "action": action, "step": step, "included": included})) } @@ -673,16 +692,21 @@ pub const RENAME_ACTION: &str = "ui.action.rename"; /// `ui.action.rename {set, action, name}`: renames the action at index `action` of set `set` (0 is /// the top set) to `name` with spaces trimmed from both ends, and keeps its steps, function key and -/// color. An index past the last set or action, or a name with nothing but spaces, is an error, and +/// color. An index past the last set or action, a name with nothing but spaces, or a name that would +/// give the set a file that Load Actions… refuses ([`edit_keeps_set_loadable`]) is an error, and /// then nothing is renamed. → {name} pub fn rename_action(app: &mut VectorcraftApp, p: &Value) -> Result { let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); let name = p.get("name").and_then(Value::as_str).map(str::trim).unwrap_or_default(); - let action = index("set").zip(index("action")).and_then(|(s, a)| app.ui.action_sets.get_mut(s)?.actions.get_mut(a)).ok_or("no such action")?; + let (set, at) = + index("set").zip(index("action")).filter(|&(s, a)| app.ui.action_sets.get(s).is_some_and(|s| a < s.actions.len())).ok_or("no such action")?; if name.is_empty() { return Err("name is empty".into()); } - action.name = name.to_string(); + let mut edited = app.ui.action_sets[set].clone(); + edited.actions[at].name = name.to_string(); + edit_keeps_set_loadable(app, Some(set), &edited)?; + app.ui.action_sets[set] = edited; Ok(json!({ "name": name })) } @@ -696,7 +720,8 @@ pub const NEW_ACTION: &str = "ui.action.new"; /// it, and `color` (one of [`COLORS`]; null or `""` for none) its Button Mode color. The action's /// function key takes priority over command shortcuts. The set becomes current and the only selected set, and the new action is selected. While an /// action is recording, and for an index past the last set, an empty name, a key or color that is -/// not one of them, nothing is added. The New Action dialog's Record runs this. → {set, action} +/// not one of them, or an action that would give the set a file that Load Actions… refuses +/// ([`edit_keeps_set_loadable`]: 1,000 actions, 8 MiB), nothing is added. The New Action dialog's Record runs this. → {set, action} pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result { if app.ui.recording.is_some() { return Err("an action is already recording".into()); @@ -714,9 +739,11 @@ pub fn new_action(app: &mut VectorcraftApp, p: &Value) -> Result } let action = Action { key, color, ..Action::new(name.clone(), vec![]) }; let id = action.id; - let Some(s) = app.ui.action_sets.get_mut(set) else { return Err("no such action set".into()) }; - s.actions.push(action); - let index = s.actions.len() - 1; + let Some(mut edited) = app.ui.action_sets.get(set).cloned() else { return Err("no such action set".into()) }; + edited.actions.push(action); + edit_keeps_set_loadable(app, Some(set), &edited)?; + let index = edited.actions.len() - 1; + app.ui.action_sets[set] = edited; start_recording(app, set, name, Some(id)); app.ui.action_set = Some(set); app.ui.action_sets_selected = vec![set]; @@ -736,7 +763,8 @@ pub const ACTION_OPTIONS: &str = "ui.action.options"; /// [`COLORS`]; null or `""` for none) its Button Mode color; a param left out keeps what the action /// has, and `cmd` or `shift` alone changes that modifier of the action's key. The action's function /// key takes priority over command shortcuts. An index past the last set or action, an empty name, -/// or a key or color that is not one of them is an error, and then nothing changes. The panel +/// a key or color that is not one of them, or a change that would give the set a file that Load +/// Actions… refuses ([`edit_keeps_set_loadable`]) is an error, and then nothing changes. The panel /// menu's Action Options… runs this for the selected action. → {name, key, color} pub fn action_options(app: &mut VectorcraftApp, p: &Value) -> Result { let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); @@ -767,7 +795,8 @@ pub fn action_options(app: &mut VectorcraftApp, p: &Value) -> Result Result Result { if app.ui.recording.is_some() { return Err("an action is already recording".into()); @@ -832,8 +866,6 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { app.ui.action_set.filter(|&i| i < app.ui.action_sets.len()).unwrap_or(0) } }; - app.ui.action_set = Some(set); - app.ui.action_sets_selected = vec![set]; let mut number = app.ui.action_sets[set].actions.len() + 1; let name = loop { let name = format!("Action {number}"); @@ -842,6 +874,12 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { } number += 1; }; + // Stop Playing/Recording adds the action even with no steps. + let mut edited = app.ui.action_sets[set].clone(); + edited.actions.push(Action::new(name.clone(), vec![])); + edit_keeps_set_loadable(app, Some(set), &edited)?; + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; start_recording(app, set, name.clone(), None); Ok(json!({ "set": set, "name": name })) } @@ -850,7 +888,8 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { /// Action made for it, else in a new action after the last action of its set, as the bottom bar's /// Stop Playing/Recording does. It also ends a playback at Step by Step or Pause For, or one that /// waits at a stop, whose message box closes: the steps already played stay, and the rest do not -/// run. With nothing recording or playing it changes nothing. → {stopped} +/// run. With nothing recording or playing it changes nothing. Recorded steps past the limits of an +/// action set file are left out ([`stop_recording`]). → {stopped} pub fn stop(app: &mut VectorcraftApp, _: &Value) -> Result { let playing = app.ui.action_playing.take().is_some(); close_stop_message(app); @@ -909,34 +948,213 @@ pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result Result<(), String> { - let Some((set, name, start)) = app.ui.recording.clone() else { return Err("no such action".into()) }; - let start = app.session.journal.held().unwrap_or(start); - let mut steps = app.session.journal_for_action(start); +/// The journal entry the recording in progress records from. +fn recording_start(app: &VectorcraftApp) -> Option { + let (_, _, start) = app.ui.recording.as_ref()?; + // A canceled undo group numbers the steps down when it drops entries made before them. + Some(app.session.journal.held().unwrap_or(*start)) +} + +/// Where Stop Playing/Recording puts the steps of the recording in progress: the index of the set, +/// and of the action New Action made for it, or None for the new action Stop adds after the set's +/// last action. None when there is no recording, or no set to put them in. +fn recording_target(app: &VectorcraftApp) -> Option<(usize, Option)> { + let (set, ..) = app.ui.recording.as_ref()?; let into = app.ui.recording_into; - let holder = into.and_then(|id| app.ui.action_sets.iter().position(|s| s.actions.iter().any(|a| a.id == id))).unwrap_or(set); - let Some(mut s) = app.ui.action_sets.get(holder).cloned() else { return Ok(()) }; - let at = match into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { + let holder = into.and_then(|id| app.ui.action_sets.iter().position(|s| s.actions.iter().any(|a| a.id == id))).unwrap_or(*set); + let s = app.ui.action_sets.get(holder)?; + Some((holder, into.and_then(|id| s.actions.iter().position(|a| a.id == id)))) +} + +/// The set that Stop Playing/Recording leaves when the recording in progress has recorded `steps`, +/// and the index of the action they go into ([`recording_target`]). +fn set_with_recorded(app: &VectorcraftApp, steps: Vec<(String, Value)>) -> Option<(ActionSet, usize)> { + let (holder, at) = recording_target(app)?; + let mut s = app.ui.action_sets.get(holder)?.clone(); + let at = match at { Some(at) => at, None => { - s.actions.push(Action::new(name, vec![])); + s.actions.push(Action::new(app.ui.recording.as_ref()?.1.clone(), vec![])); s.actions.len() - 1 } }; - if s.actions[at].steps.len() + steps.len() >= files::MAX_ACTION_STEPS { + extend_recorded(s.actions.get_mut(at)?, steps); + Some((s, at)) +} + +/// The length of the action set file of set `set`, with the steps that the recording in progress +/// adds to it ([`files::write_set`]). +fn set_file_len(app: &VectorcraftApp, set: usize) -> usize { + if recording_target(app).is_some_and(|(holder, _)| holder == set) + && let Some((s, _)) = recording_start(app).and_then(|start| set_with_recorded(app, app.session.journal_for_action(start))) + { + return files::write_set(&s).len(); + } + app.ui.action_sets.get(set).map_or(0, |s| files::write_set(s).len()) +} + +/// The most that Load Actions… reads from an action set file ([`files::read_set`]), each with the +/// error an edit past it gives: the steps of one action, the actions of a set and the bytes of the +/// file. +const SET_LIMITS: [(usize, &str); 3] = [ + (files::MAX_ACTION_STEPS, "the action already has 10,000 steps"), + (files::MAX_SET_ACTIONS, "the action set file holds more than 1,000 actions"), + (files::MAX_SET_FILE, "the action set would be larger than 8 MiB"), +]; + +/// The sizes of `set` that [`SET_LIMITS`] bound, as the set at index `at` (None for a new set): +/// the most steps one action has, the actions and the length of its file. When the recording in +/// progress records into the set at `at` ([`recording_target`]), its steps count too. +fn set_sizes(app: &VectorcraftApp, at: Option, set: &ActionSet) -> [usize; 3] { + let recorded = recording_target(app) + .filter(|&(holder, _)| Some(holder) == at) + .and_then(|_| Some((recording_start(app)?, app.ui.recording.as_ref()?.1.clone()))); + let with; + let set = match recorded { + Some((start, name)) => { + let mut s = set.clone(); + let steps = app.session.journal_for_action(start); + match app.ui.recording_into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { + Some(i) => extend_recorded(&mut s.actions[i], steps), + None => s.actions.push(Action::new(name, steps)), + } + with = s; + &with + } + None => set, + }; + [set.actions.iter().map(|a| a.steps.len()).max().unwrap_or(0), set.actions.len(), files::write_set(set).len()] +} + +/// Err when `edited`, in place of the set at index `at` (None: added as a new set), would give an +/// action set file that Load Actions… refuses ([`SET_LIMITS`]), counting the steps that the +/// recording in progress adds to it. An edit that leaves the set no further past a limit than it +/// is now is not refused. +pub(crate) fn edit_keeps_set_loadable(app: &VectorcraftApp, at: Option, edited: &ActionSet) -> Result<(), String> { + let after = set_sizes(app, at, edited); + if after.iter().zip(SET_LIMITS).all(|(&size, (limit, _))| size <= limit) { + return Ok(()); + } + let before = at.and_then(|i| app.ui.action_sets.get(i)).map_or([0; 3], |s| set_sizes(app, at, s)); + for ((size, was), (limit, error)) in after.into_iter().zip(before).zip(SET_LIMITS) { + if size > limit && size > was { + return Err(error.into()); + } + } + Ok(()) +} + +/// The number of steps the action that the recording in progress records into has of its own: 0 +/// for the new action Stop Playing/Recording adds. +fn recording_target_steps(app: &VectorcraftApp) -> usize { + recording_target(app).and_then(|(holder, at)| app.ui.action_sets.get(holder)?.actions.get(at?)).map_or(0, |a| a.steps.len()) +} + +/// The bytes that the journal entries from `start` on add to an action set file after a step +/// ([`files::step_bytes`]), at least as many as the steps recorded from them add. Only the entries +/// journaled since the last count are counted, unless the journal rewrote entries since +/// ([`crate::UiState::recording_tail`]). +fn recorded_bytes(app: &mut VectorcraftApp, start: usize) -> usize { + let journal = &app.session.journal; + let (rewrites, len) = (journal.rewrites(), journal.len()); + let (from, counted) = match app.ui.recording_tail { + Some((r, s, counted_len, bytes)) if r == rewrites && s == start && counted_len <= len => (counted_len, bytes), + _ => (start, 0), + }; + let bytes = counted + journal.since(from).iter().map(|(c, p)| files::step_bytes(c, p)).sum::(); + app.ui.recording_tail = Some((rewrites, start, len, bytes)); + bytes +} + +/// Err when adding the step `(command, params)` to the recording in progress would give the action +/// that Stop Playing/Recording keeps more than [`files::MAX_ACTION_STEPS`] steps, or its set a file +/// larger than [`files::MAX_SET_FILE`], the most Load Actions… reads. The file without the steps +/// recorded so far is measured once ([`recording_base`]), and the recorded steps add +/// [`recorded_bytes`] to it. Only when that sum is past the limit is the whole file written out to +/// measure it. +fn recording_room(app: &mut VectorcraftApp, command: &str, params: &Value) -> Result<(), String> { + let Some(start) = recording_start(app) else { return Err("no such action".into()) }; + if recording_target(app).is_none() { + return Ok(()); + } + let stored = recording_target_steps(app); + if stored + app.session.journal.since(start).len() >= files::MAX_ACTION_STEPS { return Err("the action already has 10,000 steps".into()); } - steps.push(step.clone()); - s.actions[at].steps.extend(steps); - if files::write_set(&s).len() > files::MAX_SET_FILE { + let first = if stored == 0 { files::FIRST_STEP_BYTES } else { 0 }; + let added = recorded_bytes(app, start) + files::step_bytes(command, params) + first; + if recording_base(app) + added <= files::MAX_SET_FILE { + return Ok(()); + } + let mut steps = app.session.journal_for_action(start); + steps.push((command.to_string(), params.clone())); + if set_with_recorded(app, steps).is_some_and(|(s, _)| files::write_set(&s).len() > files::MAX_SET_FILE) { return Err("the action set would be larger than 8 MiB".into()); } Ok(()) } +/// The length of the action set file that Stop Playing/Recording would leave without the steps +/// recorded so far ([`crate::UiState::recording_base`]). +fn recording_base(app: &mut VectorcraftApp) -> usize { + if let Some(base) = app.ui.recording_base { + return base; + } + let base = set_with_recorded(app, vec![]).map_or(0, |(s, _)| files::write_set(&s).len()); + app.ui.recording_base = Some(base); + base +} + +/// Err when the steps recorded so far no longer fit in the action that Stop Playing/Recording gives +/// them ([`steps_that_fit`]): a command added values to its step after [`recording_room`] measured +/// it. The steps are written out to measure them only when [`recorded_bytes`] puts them past the +/// limit. +fn recorded_steps_fit(app: &mut VectorcraftApp) -> Result<(), String> { + let Some(start) = recording_start(app) else { return Ok(()) }; + let stored = recording_target_steps(app); + let recorded = app.session.journal.since(start).len(); + let first = if stored == 0 && recorded > 0 { files::FIRST_STEP_BYTES } else { 0 }; + if stored + recorded <= files::MAX_ACTION_STEPS && recording_base(app) + recorded_bytes(app, start) + first <= files::MAX_SET_FILE { + return Ok(()); + } + let steps = app.session.journal_for_action(start); + match steps_that_fit(app, &steps) { + (_, Some(limit)) => Err(limit.to_string()), + (_, None) => Ok(()), + } +} + +/// Before the engine command `id` runs with `params`: Err when the recording in progress would keep +/// it as a step that does not fit in its action ([`recording_room`]); else whether the recording +/// would keep it, for [`after_recorded_command`]. A command is kept as a step when it is journaled, +/// a batch (`command.batch`, which journals itself) too, and Find Objects when it selects what it +/// finds. Undo and Redo are steps too, and are refused when they do not fit. +pub(crate) fn before_recorded_command(app: &mut VectorcraftApp, id: &str, params: &Value) -> Result { + let journaled = vectorcraft_engine::find_command(id).is_some_and(|c| c.journal) + || id == "command.batch" + || (id == "document.find" && params.get("select").and_then(Value::as_bool) == Some(true)); + if app.ui.recording.is_none() || !journaled || app.session.active().is_some_and(|d| d.interaction.is_some()) { + return Ok(false); + } + recording_room(app, id, params)?; + Ok(true) +} + +/// After a command that the recording keeps as a step (`recorded`, [`before_recorded_command`]) has +/// run with result `r`: when the values the command added to its step as it ran make the steps no +/// longer fit ([`recorded_steps_fit`]), the command's edit and its result stay, the status bar +/// names the limit, no later recorded command runs, and Stop Playing/Recording leaves the step out +/// ([`steps_that_fit`]) → the command's result. +pub(crate) fn after_recorded_command(app: &mut VectorcraftApp, recorded: bool, r: Result) -> Result { + if recorded + && r.is_ok() + && let Err(e) = recorded_steps_fit(app) + { + app.status(e); + } + r +} + /// What Stop Playing/Recording adds to a set ([`recorded_into`]). struct Recorded { /// The set's action that the recording records into; None for the new action that the @@ -970,7 +1188,7 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< if app.ui.recording.is_none() { return Err("no such action".into()); } - recording_room(app, &step)?; + recording_room(app, &step.0, &step.1)?; app.session.journal.push(step); return Ok(json!({"recording": true})); } @@ -1104,20 +1322,59 @@ fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Opt app.session.journal.hold(Some(start)); app.ui.recording = Some((set, name, start)); app.ui.recording_into = into; + app.ui.recording_base = None; + app.ui.recording_tail = None; +} + +/// How many of `steps`, from the first, Stop Playing/Recording can give the recording's action so +/// that its set stays one that Load Actions… reads ([`recording_room`]), and the error that names +/// the limit when that is fewer than all of them. +fn steps_that_fit(app: &VectorcraftApp, steps: &[(String, Value)]) -> (usize, Option<&'static str>) { + let Some((s, _)) = set_with_recorded(app, vec![]) else { return (steps.len(), None) }; + let stored = recording_target_steps(app); + let keep = steps.len().min(files::MAX_ACTION_STEPS.saturating_sub(stored)); + // Each step adds `step_bytes` to the file, and the action's first step a little more. + let mut size = files::write_set(&s).len() + if stored == 0 { files::FIRST_STEP_BYTES } else { 0 }; + let fitting = steps[..keep] + .iter() + .take_while(|(c, p)| { + size += files::step_bytes(c, p); + size <= files::MAX_SET_FILE + }) + .count(); + if fitting < keep { + return (fitting, Some("the action set would be larger than 8 MiB")); + } + (keep, (keep < steps.len()).then_some("the action already has 10,000 steps")) +} + +/// Adds the recorded `steps` after the steps of `action`, included in playback. An exclusion past +/// the action's last step, which saved preferences can hold, goes. +fn extend_recorded(action: &mut Action, steps: Vec<(String, Value)>) { + let len = action.steps.len(); + action.excluded_steps.retain(|&i| i < len); + action.steps.extend(steps); } /// Stop Playing/Recording: ends the recording and keeps its steps (see [`start_recording`]). An -/// action New Action made that was deleted while it recorded keeps nothing. +/// action New Action made that was deleted while it recorded keeps nothing. Steps past the limits +/// of an action set file ([`steps_that_fit`]) are left out, and the status bar names the limit. fn stop_recording(app: &mut VectorcraftApp) { - let Some((set, name, start)) = app.ui.recording.take() else { return }; - // A canceled undo group numbers the steps down when it drops entries made before them. - let start = app.session.journal.held().unwrap_or(start); - let steps = app.session.journal_for_action(start); + let Some(start) = recording_start(app) else { return }; + let mut steps = app.session.journal_for_action(start); + let (keep, limit) = steps_that_fit(app, &steps); + steps.truncate(keep); + if let Some(limit) = limit { + app.status(limit); + } + let Some((set, name, _)) = app.ui.recording.take() else { return }; + app.ui.recording_base = None; + app.ui.recording_tail = None; app.session.journal.hold(None); match app.ui.recording_into.take() { Some(id) => { if let Some(a) = app.ui.action_sets.iter_mut().flat_map(|s| s.actions.iter_mut()).find(|a| a.id == id) { - a.steps.extend(steps); + extend_recorded(a, steps); } } None => { @@ -1342,7 +1599,9 @@ fn duplicate_selection(app: &mut VectorcraftApp, ctx: &egui::Context) { /// named after the original with " copy" added, and a copied set gets an id of its own /// ([`ActionSet::id`]). Copied actions have no function key, so a key plays only the original. /// The copy is selected and its set is the current one. A recording and an open Set Options or -/// Batch dialog keep to their own set and action. → {set, action?} +/// Batch dialog keep to their own set and action. A copy that would give its set a file that Load +/// Actions… refuses ([`edit_keeps_set_loadable`]) is an error, and nothing is copied. +/// → {set, action?} pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { const SELECTED: &str = "actions-selected"; let len = app.ui.action_sets.len(); @@ -1351,7 +1610,7 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { None | Some(Value::Null) => app.ui.action_set.filter(|&s| s < len).ok_or("no action set is current")?, Some(v) => index(v).filter(|&s| s < len).ok_or("no such action set")?, }; - let Some(src) = app.ui.action_sets.get_mut(set) else { return Err("no such action set".into()) }; + let Some(src) = app.ui.action_sets.get(set) else { return Err("no such action set".into()) }; let action = match p.get("action") { None | Some(Value::Null) => None, Some(v) => Some(index(v).filter(|&a| a < src.actions.len()).ok_or("no such action")?), @@ -1359,7 +1618,10 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { let (copy, result) = if let Some(a) = action { let Some(mut copy) = src.actions.get(a).map(Action::copy) else { return Err("no such action".into()) }; copy.name = format!("{} copy", copy.name); - src.actions.insert(a + 1, copy); + let mut edited = src.clone(); + edited.actions.insert(a + 1, copy); + edit_keeps_set_loadable(app, Some(set), &edited)?; + app.ui.action_sets[set] = edited; // A Batch, Record Stop or Insert Menu Item dialog open on a later action of the set follows it // down. if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || holds_step(d)) @@ -1371,6 +1633,7 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result { (Some((set, a + 1)), json!({ "set": set, "action": a + 1 })) } else { let copy = ActionSet::new(format!("{} copy", src.name), src.actions.iter().map(Action::copy).collect()); + edit_keeps_set_loadable(app, None, ©)?; app.ui.action_sets.insert(set + 1, copy); if let Some((rec, ..)) = app.ui.recording.as_mut() && *rec > set @@ -4258,12 +4521,12 @@ mod tests { assert_eq!(files::read_set(&saved, "Test").unwrap().actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS, "and loads again"); let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); app.ui.action_sets[0].actions[0].steps = vec![pad(room - 8)]; let into = app.ui.action_sets[0].actions[0].id; - app.run(START_RECORDING, json!({})).unwrap(); app.ui.recording_into = Some(into); let size = "the action set would be larger than 8 MiB"; assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap_err(), size); @@ -4320,6 +4583,457 @@ mod tests { } } + /// `files::step_bytes` is what a step adds to the file after another step, and + /// `files::FIRST_STEP_BYTES` less than it adds as an action's first step, with quotes, + /// backslashes, control characters, text outside ASCII, nested params, a stop that changes the + /// file's version and empty params. Excluding a step adds `files::EXCLUDED_BYTES`. + #[test] + fn step_bytes_count_what_a_step_adds_to_an_action_set_file() { + let steps = [ + ("object.move", json!({"dx": 1, "dy": 0})), + ("type.set", json!({"text": "say \"hi\" \\ back\tslash\nline \u{1} é 日本 🎨 "})), + ( + "command.batch", + json!({"label": "B", "commands": [{"command": "object.move", "params": {"dx": 1, "n": {"a": [1, 2.5, {"b": null}]}}}]}), + ), + (STOP_STEP, json!({"message": "Paint it", "allowContinue": false})), + ("object.group", json!({})), + ("object.group", json!([])), + ("object.group", Value::Null), + ]; + for (command, params) in steps { + for first in [true, false] { + let before = if first { vec![] } else { vec![("object.group".to_string(), json!({}))] }; + let mut set = ActionSet::new("Set", vec![Action::new("A", before), Action::new("B", vec![("object.group".into(), json!({}))])]); + let len = files::write_set(&set).len(); + set.actions[0].steps.push((command.to_string(), params.clone())); + let added = files::write_set(&set).len() - len; + let counted = files::step_bytes(command, ¶ms); + let first_bytes = if first { files::FIRST_STEP_BYTES } else { 0 }; + assert_eq!(counted + first_bytes, added, "{command} {params}, first: {first}"); + } + } + let mut set = ActionSet::new("Set", vec![Action::new("A", vec![("object.group".into(), json!({"n": "日本"})); 3])]); + let len = files::write_set(&set).len(); + set.actions[0].excluded_steps = BTreeSet::from([0, 2]); + assert_eq!(files::write_set(&set).len(), len + 2 * files::EXCLUDED_BYTES); + } + + /// The params of `object.move` by 1 as the journal keeps them, which a recording records. + fn recorded_move(app: &mut VectorcraftApp) -> Value { + app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap(); + let moved = app.session.journal.last().unwrap().1.clone(); + app.run("edit.undo", json!({})).unwrap(); + moved + } + + /// An ordinary command recorded as the 10,001st step of an action does not run: the document, + /// the journal and the undo history stay as they were, the recording goes on, and Stop + /// Playing/Recording leaves a set that Load Actions… reads. This holds for Begin Recording's new + /// action and for New Action's recording into an action with steps of its own. + #[test] + fn recorded_commands_keep_an_action_at_10_000_steps() { + for new_action in [false, true] { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let filler = ("object.group".to_string(), json!({})); + if new_action { + app.run(NEW_ACTION, json!({"set": 0, "name": "Long"})).unwrap(); + app.ui.action_sets[0].actions[1].steps = vec![filler; files::MAX_ACTION_STEPS - 1]; + } else { + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 1..files::MAX_ACTION_STEPS { + app.session.journal.push(filler.clone()); + } + } + app.run("object.move", moved.clone()).unwrap(); + assert_eq!(x(&app), 1.0, "the 10,000th step runs"); + let (journal, undo) = (app.session.journal.len(), undo_steps(&app)); + let steps = "the action already has 10,000 steps"; + assert_eq!(app.run("object.move", moved.clone()).unwrap_err(), steps, "new action: {new_action}"); + assert_eq!((x(&app), app.session.journal.len(), undo_steps(&app)), (1.0, journal, undo), "nothing ran"); + assert_eq!(app.ui.status, steps); + assert!(app.ui.recording.is_some(), "the recording goes on"); + assert!(app.run("effect.list", json!({})).is_ok(), "a command that is not recorded still runs"); + let find = |select: bool| json!({"minWidth": 0, "select": select}); + assert_eq!(app.run("document.find", find(true)).unwrap_err(), steps, "Find Objects that selects is recorded"); + assert!(app.run("document.find", find(false)).is_ok()); + let batch = json!({"commands": [{"command": "object.move", "params": moved.clone()}]}); + assert_eq!(app.run("command.batch", batch).unwrap_err(), steps, "a batch is recorded"); + assert_eq!(x(&app), 1.0); + assert_eq!(app.session.journal.len(), journal); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[1].steps.len(), files::MAX_ACTION_STEPS); + assert_eq!(app.ui.action_sets[0].actions[1].steps.last(), Some(&("object.move".to_string(), moved))); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[1].steps.len(), files::MAX_ACTION_STEPS, "and loads again"); + } + } + + /// An ordinary command whose step would give the set of the action it is recorded into a file + /// larger than 8 MiB does not run, and one that makes the file exactly 8 MiB does. + #[test] + fn recorded_commands_keep_an_action_set_file_within_8_mib() { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + let added = files::step_bytes("object.move", &moved); + app.ui.action_sets[0].actions[0].steps = vec![pad(room - added)]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved.clone()).unwrap(); + assert_eq!(x(&app), 1.0, "the step that fills the file runs"); + let (journal, undo) = (app.session.journal.len(), undo_steps(&app)); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run("object.move", moved).unwrap_err(), size); + assert_eq!((x(&app), app.session.journal.len(), undo_steps(&app)), (1.0, journal, undo), "nothing ran"); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.ui.status.clear(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "", "Stop keeps every recorded step"); + let saved = files::write_set(&app.ui.action_sets[0]); + assert_eq!(saved.len(), files::MAX_SET_FILE); + assert_eq!(files::read_set(&saved, "Test").unwrap().actions[0].steps.len(), 2, "and loads again"); + } + + /// A command whose step fits by its params but not with the values it adds to its step as it + /// runs (Scale's Scale Strokes & Effects and Scale Corners) keeps its edit, the status bar names + /// the limit, the next recorded command does not run, and Stop Playing/Recording leaves the step + /// out. + #[test] + fn a_recorded_command_whose_noted_values_do_not_fit_is_left_out() { + let (mut app, x) = app_with_three_moves(); + let scaled = json!({"sx": 200}); + app.run("object.scale", scaled.clone()).unwrap(); + let noted = app.session.journal.last().unwrap().1.clone(); + app.run("edit.undo", json!({})).unwrap(); + assert!(files::step_bytes("object.scale", ¬ed) > files::step_bytes("object.scale", &scaled), "Scale notes values: {noted}"); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions[0].steps = vec![pad(0)]; + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + // Room for the step as given, not for the step as recorded. + app.ui.action_sets[0].actions[0].steps = vec![pad(room - files::step_bytes("object.scale", &scaled))]; + let into = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({})).unwrap(); + app.ui.recording_into = Some(into); + let size = "the action set would be larger than 8 MiB"; + app.run("object.scale", scaled).unwrap(); + assert_eq!(app.ui.status, size); + let scaled_x = x(&app); + assert_ne!(scaled_x, 0.0, "the scale stayed"); + assert_eq!(app.run("object.move", json!({"dx": 1, "dy": 0})).unwrap_err(), size, "no later command runs"); + assert_eq!(x(&app), scaled_x); + assert!(app.ui.recording.is_some(), "the recording goes on"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "the scale's step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// A stop that playback reaches while a full action records is not recorded: the status bar shows + /// the reason, and the playback still waits at the stop. + #[test] + fn a_stop_reached_while_recording_a_full_action_is_left_out() { + let (mut app, _) = app_with_a_stop(false); + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 1..files::MAX_ACTION_STEPS { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + play(&mut app, 0, 0).unwrap(); + assert!(waiting_at_stop(&app)); + assert_eq!(app.ui.status, "the action already has 10,000 steps"); + app.run(STOP_RECORDING, json!({})).unwrap(); + let recorded = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!(recorded.steps.len(), files::MAX_ACTION_STEPS); + assert_eq!(recorded.steps.last().map(|(c, _)| c.as_str()), Some("command.batch")); + } + + /// Steps that reach the journal without a command's check are left out by Stop Playing/Recording + /// past either limit, and the status bar names the limit. + #[test] + fn stop_leaves_out_recorded_steps_past_the_limits() { + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({})).unwrap(); + for _ in 0..files::MAX_ACTION_STEPS + 5 { + app.session.journal.push(("object.group".to_string(), json!({}))); + } + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "the action already has 10,000 steps"); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS); + + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.run(START_RECORDING, json!({})).unwrap(); + app.session.journal.push(pad(files::MAX_SET_FILE / 2)); + app.session.journal.push(pad(files::MAX_SET_FILE / 2)); + app.session.journal.push(pad(0)); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.status, "the action set would be larger than 8 MiB"); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), 1); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// An exclusion that would give a set a file larger than 8 MiB is refused for a set, an action, + /// a step and the other steps of an action, and leaves every exclusion as it was. One that + /// fills the file exactly, one that changes nothing and an inclusion still work, also in a set + /// that is already larger than 8 MiB. + #[test] + fn excluding_steps_keeps_an_action_set_file_within_8_mib() { + let (mut app, _) = app_with_three_moves(); + let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)})); + app.ui.action_sets[0].actions.push(Action::new("Pad", vec![pad(0)])); + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + // Room for two more excluded steps. + app.ui.action_sets[0].actions[1].steps = vec![pad(room - 2 * files::EXCLUDED_BYTES)]; + let size = "the action set would be larger than 8 MiB"; + let include = |app: &mut VectorcraftApp, p: Value| app.run(INCLUDE_STEPS, p); + let excluded = |app: &VectorcraftApp| app.ui.action_sets[0].actions.iter().map(|a| a.excluded_steps.clone()).collect::>(); + let none = vec![BTreeSet::new(), BTreeSet::new()]; + assert_eq!(include(&mut app, json!({"set": 0, "included": false})).unwrap_err(), size); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "included": false})).unwrap_err(), size); + assert_eq!(excluded(&app), none, "nothing changed"); + // Two steps fill the file to 8 MiB. + include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false, "others": true})).unwrap(); + assert_eq!(excluded(&app), [BTreeSet::from([1, 2]), BTreeSet::new()]); + assert_eq!(files::write_set(&app.ui.action_sets[0]).len(), files::MAX_SET_FILE); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false})).unwrap_err(), size); + assert_eq!(include(&mut app, json!({"set": 0, "action": 1, "step": 0, "included": false})).unwrap_err(), size); + assert!(include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": false})).is_ok(), "no change"); + include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": true})).unwrap(); + include(&mut app, json!({"set": 0, "action": 0, "step": 0, "included": false})).unwrap(); + assert_eq!(excluded(&app), [BTreeSet::from([0, 2]), BTreeSet::new()]); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + + // A set already larger than 8 MiB can still include its steps. + app.ui.action_sets[0].actions[1].steps[0] = pad(room); + assert_eq!(include(&mut app, json!({"set": 0, "action": 0, "step": 1, "included": false})).unwrap_err(), size); + include(&mut app, json!({"set": 0, "included": true})).unwrap(); + assert_eq!(excluded(&app), none); + + // The steps a recording adds to the set count too. + let (mut app, _) = app_with_three_moves(); + let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len(); + app.run(START_RECORDING, json!({})).unwrap(); + app.session.journal.push(pad(room - files::step_bytes("object.move", &pad(0).1))); + assert_eq!(include(&mut app, json!({"set": 0, "included": false})).unwrap_err(), size); + assert!(app.ui.action_sets[0].actions[0].excluded_steps.is_empty()); + } + + /// Adds an action to set `set` whose step makes the set's file `short` bytes short of 8 MiB. + fn pad_set_to(app: &mut VectorcraftApp, set: usize, short: usize) { + let pad = |n: usize| ("object.move".to_string(), json!({"pad": "x".repeat(n)})); + let s = &mut app.ui.action_sets[set]; + s.actions.push(Action::new("Pad", vec![pad(0)])); + let room = files::MAX_SET_FILE - files::write_set(s).len(); + s.actions.last_mut().unwrap().steps[0] = pad(room - short); + assert_eq!(files::write_set(s).len(), files::MAX_SET_FILE - short); + } + + /// `n` actions with no steps, named A0, A1 … + fn empty_actions(n: usize) -> Vec { + (0..n).map(|i| Action::new(format!("A{i}"), vec![])).collect() + } + + /// A recorded batch that edits another document, and whose values make its step too large only + /// as it runs, keeps its edits in both documents: the status bar names the limit, the next + /// recorded command does not run, and Stop Playing/Recording leaves the batch's step out. + #[test] + fn a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows() { + // Three moves' document, a move as recorded there, and a second document that is active. + let two_documents = || { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + (app, x, moved) + }; + let batch = json!({"commands": [ + {"command": "document.activate", "params": {"index": 0}}, + {"command": "object.scale", "params": {"sx": 200}}, + ]}); + let (mut app, _, _) = two_documents(); + app.run("command.batch", batch.clone()).unwrap(); + let noted = app.session.journal.last().unwrap().1.clone(); + // The batch as the app runs it (with the active artboard) before the scale notes its values. + let mut sent = noted.clone(); + sent["commands"][1]["params"] = json!({"sx": 200}); + let given = files::step_bytes("command.batch", &sent); + assert!(files::step_bytes("command.batch", ¬ed) > given, "the scale notes values: {noted}"); + + let (mut app, x, moved) = two_documents(); + pad_set_to(&mut app, 0, given); + let into = app.ui.action_sets[0].actions.last().unwrap().id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("command.batch", batch).unwrap(); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.ui.status, size); + let scaled = x(&app); + assert_ne!(scaled, 0.0, "the scale stayed"); + assert_eq!(app.run("object.move", moved).unwrap_err(), size, "no later command runs"); + assert_eq!(x(&app), scaled); + let pad = app.ui.action_sets[0].actions.last().unwrap().steps.len(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.last().unwrap().steps.len(), pad, "the batch's step is left out"); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// Set Options… renaming the set that an action records into has the next recorded command + /// measure the set's file again: a step that fit before the longer name does not run after it. + #[test] + fn a_set_renamed_while_it_records_is_measured_again() { + let (mut app, x) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let added = files::step_bytes("object.move", &moved); + pad_set_to(&mut app, 0, 2 * added + 3); + let into = app.ui.action_sets[0].actions.last().unwrap().id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved.clone()).unwrap(); + crate::dialogs::action_set_options::open(&mut app, 0); + let longer = format!("{} 1234", app.ui.action_sets[0].name); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!(longer)); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(app.ui.action_sets[0].name, longer); + assert_eq!(app.run("object.move", moved).unwrap_err(), "the action set would be larger than 8 MiB"); + assert_eq!(x(&app), 1.0, "only the first move ran"); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert!(files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").is_ok()); + } + + /// Steps recorded into an action are included in playback, also when the action holds an + /// exclusion past its last step, as saved preferences can; the set's file stays within 8 MiB. + #[test] + fn steps_recorded_after_an_exclusion_past_the_last_step_are_included() { + let (mut app, _) = app_with_three_moves(); + let moved = recorded_move(&mut app); + let added = files::step_bytes("object.move", &moved); + pad_set_to(&mut app, 0, added); + let pad = app.ui.action_sets[0].actions.last_mut().unwrap(); + pad.excluded_steps.insert(1); + let into = pad.id; + app.run(START_RECORDING, json!({"set": 0})).unwrap(); + app.ui.recording_into = Some(into); + app.run("object.move", moved).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + let pad = app.ui.action_sets[0].actions.last().unwrap(); + assert_eq!((pad.steps.len(), pad.excluded_steps.len()), (2, 0), "the recorded step is included"); + assert_eq!(files::write_set(&app.ui.action_sets[0]).len(), files::MAX_SET_FILE); + } + + /// Duplicate copies no action or set when the copy would give a file larger than 8 MiB or a set + /// of more than 1,000 actions, and still copies one that fits. + #[test] + fn duplicate_keeps_action_set_files_within_their_limits() { + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 0); + let size = "the action set would be larger than 8 MiB"; + let names = set_names(&app); + assert_eq!(app.run(DUPLICATE, json!({"set": 1, "action": 0})).unwrap_err(), size); + assert_eq!(app.run(DUPLICATE, json!({"set": 1})).unwrap_err(), size, "the copy's name is longer"); + assert_eq!((set_names(&app), app.ui.action_sets[1].actions.len()), (names, 3), "nothing was copied"); + app.ui.action_sets[2].actions = empty_actions(1000); + let many = "the action set file holds more than 1,000 actions"; + assert_eq!(app.run(DUPLICATE, json!({"set": 2, "action": 0})).unwrap_err(), many); + assert_eq!(app.ui.action_sets[2].actions.len(), 1000); + app.ui.action_sets[2].actions.pop(); + app.run(DUPLICATE, json!({"set": 2, "action": 0})).unwrap(); + app.run(DUPLICATE, json!({"set": 2})).unwrap(); + assert_eq!((app.ui.action_sets[2].actions.len(), app.ui.action_sets[3].actions.len()), (1000, 1000)); + } + + /// New Action and Begin Recording add no action and start no recording when the action would + /// give its set more than 1,000 actions or a file larger than 8 MiB, and New Action adds one that + /// fits. + #[test] + fn new_action_keeps_an_action_set_file_within_its_limits() { + let mut app = app_with_sets(); + app.ui.action_sets[2].actions = empty_actions(1000); + let many = "the action set file holds more than 1,000 actions"; + assert_eq!(app.run(NEW_ACTION, json!({"set": 2, "name": "More"})).unwrap_err(), many); + assert_eq!(app.run(START_RECORDING, json!({"set": 2})).unwrap_err(), many, "Begin Recording too"); + pad_set_to(&mut app, 1, 200); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(NEW_ACTION, json!({"set": 1, "name": "x".repeat(300)})).unwrap_err(), size); + assert!(app.ui.recording.is_none(), "no recording started"); + assert_eq!((app.ui.action_sets[1].actions.len(), app.ui.action_sets[2].actions.len()), (3, 1000)); + app.run(NEW_ACTION, json!({"set": 1, "name": "More"})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[1].actions.len(), 4); + assert!(files::write_set(&app.ui.action_sets[1]).len() <= files::MAX_SET_FILE); + } + + /// Renaming an action, Action Options and Set Options change nothing when the change would give + /// the set a file larger than 8 MiB, with the steps a recording adds to the set; a change that + /// fills the file exactly, or that makes a set already past 8 MiB smaller, is made. New Set adds + /// no set whose file would be larger than 8 MiB. + #[test] + fn renaming_keeps_an_action_set_file_within_8_mib() { + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 2); + let size = "the action set would be larger than 8 MiB"; + assert_eq!(app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge Far"})).unwrap_err(), size); + assert_eq!(app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "color": "red"})).unwrap_err(), size); + assert_eq!(app.run(ACTION_OPTIONS, json!({"set": 1, "action": 0, "key": 2})).unwrap_err(), size); + let nudge = &app.ui.action_sets[1].actions[0]; + assert_eq!((nudge.name.as_str(), nudge.key, nudge.color.as_deref()), ("Nudge", None, None), "nothing changed"); + crate::dialogs::action_set_options::open(&mut app, 1); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Mine and More")); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), size); + assert!(app.ui.dialog.is_some(), "Set Options stays open"); + assert_eq!(app.ui.action_sets[1].name, "Mine"); + app.ui.dialog = None; + app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge++"})).unwrap(); + assert_eq!(files::write_set(&app.ui.action_sets[1]).len(), files::MAX_SET_FILE, "the file is exactly 8 MiB"); + app.ui.action_sets[1].actions[1].name = "x".repeat(100); + app.run(RENAME_ACTION, json!({"set": 1, "action": 1, "name": "Nudge Again"})).unwrap(); + assert_eq!(app.ui.action_sets[1].actions[1].name, "Nudge Again", "a set past 8 MiB gets smaller"); + + crate::dialogs::action_set_options::open_new(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("x".repeat(files::MAX_SET_FILE))); + assert_eq!(crate::dialogs::confirm(&mut app).unwrap_err(), size); + assert_eq!(app.ui.action_sets.len(), 3, "no set was added"); + app.ui.dialog = None; + + // The steps a recording adds to the set count too. + let mut app = app_with_sets(); + pad_set_to(&mut app, 1, 1000); + app.run(START_RECORDING, json!({"set": 1})).unwrap(); + app.session.journal.push(("object.move".to_string(), json!({"pad": "x".repeat(1000)}))); + assert_eq!(app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudge+"})).unwrap_err(), size); + app.run(RENAME_ACTION, json!({"set": 1, "action": 0, "name": "Nudg"})).unwrap(); + } + + /// Load Actions… adds no set that Save Actions… would write as a file larger than 8 MiB: a file + /// with less white space, a file whose set name gets a number, and another app's action set whose + /// names take more bytes in the file. + #[test] + fn load_actions_refuses_a_set_that_would_save_larger_than_8_mib() { + let mut app = app_with_sets(); + let size = "the action set would be larger than 8 MiB"; + let wide = ActionSet::new("Wide", vec![Action::new("Zeros", vec![("object.move".into(), json!({"p": vec![0; 600_000]}))])]); + let saved = files::write_set(&wide); + let packed = serde_json::to_string(&serde_json::from_str::(&saved).unwrap()).unwrap(); + assert!(packed.len() <= files::MAX_SET_FILE && saved.len() > files::MAX_SET_FILE); + assert_eq!(app.run(LOAD_SET, json!({ "data": packed })).unwrap_err(), size); + + let mut full = app_with_sets(); + full.ui.action_sets[1].actions.clear(); + pad_set_to(&mut full, 1, 0); + let mine = files::write_set(&full.ui.action_sets[1]); + assert_eq!(app.run(LOAD_SET, json!({ "data": mine })).unwrap_err(), size, "Mine 2 is longer"); + let yours = mine.replacen("\"Mine\"", "\"Your\"", 1); + assert_eq!(app.run(LOAD_SET, json!({ "data": yours })).unwrap()["name"], "Your"); + + let name = "\u{1}".repeat(1024); + let steps = [("Group", true); 40]; + let text = aia_text("Names", &vec![(name.as_str(), steps.as_slice()); 1000]); + assert!(text.len() <= files::MAX_SET_FILE); + assert_eq!(app.run(LOAD_SET, json!({ "data": text })).unwrap_err(), size); + assert_eq!(set_names(&app), ["Default Actions", "Mine", "Other", "Your"]); + } + /// A step inserted from the menu saves and loads as the command and params it holds, with its /// place and exclusion. #[test] diff --git a/crates/ui-egui/src/panels/actions/files.rs b/crates/ui-egui/src/panels/actions/files.rs index 2e1385a7d8..fd8b40fbd7 100644 --- a/crates/ui-egui/src/panels/actions/files.rs +++ b/crates/ui-egui/src/panels/actions/files.rs @@ -31,7 +31,7 @@ pub const ACTIONS_EXT: &str = "vcactions"; pub(super) const MAX_SET_FILE: usize = 8 << 20; /// The most actions [`read_set`] reads from one file. -const MAX_SET_ACTIONS: usize = 1000; +pub(super) const MAX_SET_ACTIONS: usize = 1000; /// The most steps [`read_set`] reads in one action. pub(super) const MAX_ACTION_STEPS: usize = 10_000; @@ -74,6 +74,51 @@ fn is_false(value: &bool) -> bool { !value } +/// A step as [`write_set`] writes it when it is included: [`FileStep`] without `excluded`. +#[derive(Serialize)] +struct IncludedStep<'a> { + command: &'a str, + params: &'a Value, +} + +/// The bytes that excluding one step adds to the text [`write_set`] writes: a comma, a line +/// break, the indent and `"excluded": true`. +pub(super) const EXCLUDED_BYTES: usize = 28; + +/// The bytes more that a step adds to the text [`write_set`] writes as an action's first step than +/// [`step_bytes`] counts: a line break on each side of the step and the indent of the closing +/// bracket, in place of a comma and a line break. +pub(super) const FIRST_STEP_BYTES: usize = 6; + +/// The bytes that the included step `(command, params)` adds to the text [`write_set`] writes when +/// it goes after the last step of an action. As the action's first step it adds +/// [`FIRST_STEP_BYTES`] more. +pub(super) fn step_bytes(command: &str, params: &Value) -> usize { + /// Counts the bytes and the line breaks written to it. + #[derive(Default)] + struct Count { + bytes: usize, + breaks: usize, + } + impl std::io::Write for Count { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.bytes += buf.len(); + self.breaks += buf.iter().filter(|&&b| b == b'\n').count(); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut count = Count::default(); + if serde_json::to_writer_pretty(&mut count, &IncludedStep { command, params }).is_err() { + return MAX_SET_FILE + 1; + } + // In the file every line of the step is indented by 8 more spaces, and a comma and a line break + // come before it. + count.bytes + 8 * (count.breaks + 1) + 2 +} + /// The params of the stops in a step: a stop's own, or those of the stops a `command.batch` step /// holds, which playback runs as its own steps. fn stops_in<'a>(command: &str, params: &'a Value) -> Vec<&'a Value> { @@ -329,6 +374,8 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { } let AiaLoad { mut set, skipped, left_out } = aia_set(&read, "Actions"); set.name = free_set_name(&app.ui.action_sets, &set.name); + // The set must save as a file that Load Actions… reads, which can be larger than the file read. + super::edit_keeps_set_loadable(app, None, &set)?; let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); if left_out > 0 { @@ -350,14 +397,17 @@ fn load_aia(app: &mut VectorcraftApp, params: Value) -> Result { /// same name as one in the panel gets the next free number (`Print 2`). Loaded actions keep their /// function keys. The current set and the other sets stay as they are → /// `{set: index, name, actions: n}`. Where the host can read part of a file (the desktop), a file -/// larger than 8 MiB is refused after its first 8 MiB and one byte are read. +/// larger than 8 MiB is refused after its first 8 MiB and one byte are read. A set that Save +/// Actions… would write as a file Load Actions… refuses (larger than 8 MiB, as a file with less +/// white space or a name that gets a number can give) is refused too, and no set is added. /// /// An action set another app saved as text (`.aia`, read with `actionSet.read`) comes in with its /// actions and the steps that stand for a VectorCraft command ([`step_name`], [`step_commands`]), /// with no params; a step that is off in the file is excluded from playback. A set with no name is /// called after the file, else `Actions`. The other steps are left out and listed, and the status /// bar says how many → `{set, name, actions, skipped: [{action, step}] (at most 1,000), leftOut: n}`. -/// Such a file that `actionSet.read` marks `truncated` is refused, and no set is added. +/// Such a file that `actionSet.read` marks `truncated` is refused, and so is one whose set Save +/// Actions… would write larger than 8 MiB; then no set is added. pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { let (text, stem) = match p.get("data") { Some(Value::String(data)) => { @@ -398,6 +448,8 @@ pub fn load_set(app: &mut VectorcraftApp, p: &Value) -> Result { }; let mut set = read_set(&text, if stem.is_empty() { "Actions" } else { &stem })?; set.name = free_set_name(&app.ui.action_sets, &set.name); + // A number added to the name can make a file at the limit larger than it. + super::edit_keeps_set_loadable(app, None, &set)?; let (name, actions) = (set.name.clone(), set.actions.len()); app.ui.action_sets.push(set); Ok(json!({ "set": app.ui.action_sets.len() - 1, "name": name, "actions": actions })) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 4a0e73da6b..2a65c24d16 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -590,6 +590,16 @@ pub struct UiState { /// adds a new action, as after Begin Recording. #[serde(skip)] pub recording_into: Option, + /// The length of the action set file that Stop Playing/Recording would leave without the steps + /// recorded so far, once measured; None until then, and again after an app command, which may + /// change the sets. + #[serde(skip)] + pub recording_base: Option, + /// The bytes that the steps recorded so far add to that file, at most, as last counted: (the + /// journal's rewrites, the recording's first entry, the journal's length, the bytes) + /// ([`vectorcraft_engine::Journal::rewrites`]). + #[serde(skip)] + pub recording_tail: Option<(u64, usize, usize, usize)>, /// How actions play (Actions panel menu › Playback Options…), kept with the UI preferences. #[serde(default, deserialize_with = "crate::panels::actions::deserialize_playback")] pub action_playback: crate::panels::actions::PlaybackOptions, @@ -1094,6 +1104,8 @@ impl Default for UiState { action_sets_selected: vec![], recording: None, recording_into: None, + recording_base: None, + recording_tail: None, action_playback: Default::default(), action_playing: None, shortcut_overrides: Default::default(), diff --git a/docs/mcp.md b/docs/mcp.md index 996908799f..bb4d4c0802 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2818,16 +2818,23 @@ already walks Image Trace. Short agent entry points: use version 2, so older builds refuse them instead of playing the excluded steps or failing at a stop; other files use version 1. A file with another `format` or a `version` other than 1, 2 or 3, a stop whose message is not text or whose `allowContinue` is not true or false, a file larger than 8 MiB, one with more than 1,000 actions and one with an action of more than 10,000 steps are - refused, and no set is added. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is + refused, and no set is added. So is a file whose set `ui.actionSet.save` would write larger than 8 MiB (a file with + less white space, a set name that gets a number, or an `.aia` set), with the error `the action set would be larger + than 8 MiB`. The Actions panel menu's Save Actions… and Load Actions… run these; Load Actions… is disabled where there is no open dialog (the web). `ui.actionSet.load` also reads the action sets other apps save as text (`.aia`); see "Action sets from other apps". - Actions: `ui.action.rename {set, action, name}` renames the Actions panel's action at `action` in set `set` (0 is the top set) to `name` with spaces trimmed from both ends, keeps its steps, function key and color, and returns - `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. + `{name}`. An index past the last set or action, or an empty name, is an error, and then nothing is renamed. So is a + name that would give the set's action set file more than 8 MiB, counting the steps the recording in progress adds + to the set (`the action set would be larger than 8 MiB`); a name that leaves the file as large or makes it smaller + is never refused. The same holds for `ui.action.options` and for the names Set Options… and New Set… give. - Actions: `ui.action.stepsIncluded {set, action?, step?, included, others?}` includes or excludes all steps of a set, one action, or one step. With `step` and `others: true`, it leaves that step as it was and applies `included` to the - other steps of its action (the panel's Alt/Option-click). An invalid set, action or step changes nothing. The panel's - checkmarks run this command; normal playback and Batch… skip excluded steps. + other steps of its action (the panel's Alt/Option-click). An invalid set, action or step changes nothing. So does an + exclusion that would give the set's action set file more than 8 MiB, counting the steps the recording in progress + adds to the set; it is an error. A change that leaves the file as large or makes it smaller is never refused. The + panel's checkmarks run this command; normal playback and Batch… skip excluded steps. - Actions: `ui.action.new {set, name, key?, cmd?, shift?, color?}` adds an action named `name` (spaces trimmed) after the last action of set `set` (0 is the top set), starts recording into it and returns `{set, action}`; Stop Playing/Recording (`ui.action.stop`) ends the recording. `key` 1 to 15 is the function key F1 to F15 that plays the @@ -2835,12 +2842,23 @@ already walks Image Trace. Short agent entry points: (`red`, `yellow`, `green`, `blue` or `violet`; null or `""` for none), which the panel does not show. Action function keys take priority over command shortcuts and preserve the command's binding. On Windows F1, Ctrl+F4 and Ctrl+F6 cannot be used. While an action is recording, and for an index past the last set, - an empty name, or a key or color that is not one of these, it is an error and nothing is added. The panel's Create + an empty name, or a key or color that is not one of these, it is an error and nothing is added. So is an action that + would give the set more than 1,000 actions (`the action set file holds more than 1,000 actions`) or a file larger + than 8 MiB (`the action set would be larger than 8 MiB`), and then no recording starts. The panel's Create New Action button and menu's New Action… open the `actionNew` dialog, whose Record runs this. - Actions: `ui.action.record {set?}` starts recording a new action in the chosen set, or the current set when `set` is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next available `Action N` name in that set. The Actions panel's Begin Recording button runs this command. An invalid set or an - existing recording is an error and leaves the sets and recording unchanged. + existing recording is an error and leaves the sets and recording unchanged, and so is a set where the new action + would give it more than 1,000 actions or an action set file larger than 8 MiB. While a recording (this one or + `ui.action.new`'s) is in progress, a recorded command whose step would give the action more than 10,000 steps, or + its set's action set file more than 8 MiB, is an error with the reason (`the action already has 10,000 steps`, `the + action set would be larger than 8 MiB`): it does not run, and the recording goes on. A batch (`command.batch`) is + such a command too. A command whose step grows past a limit only as it runs, from values it adds to its step, + keeps its edit and result; the status bar names the limit, later recorded commands do not run, and + `ui.action.stop` leaves its step out. A stop that playback reaches + then is not recorded, and the status bar shows the reason. `ui.action.stop` leaves out any recorded steps still past + either limit and shows which one in the status bar. - Actions: `ui.action.options {set, action, name?, key?, cmd?, shift?, color?}` changes the action at `action` in set `set` and keeps its steps: `name` renames it, `key` with `cmd` and `shift` sets its function key, and `color` its Button Mode color, as `ui.action.new` takes them; a param left out keeps what the action has, and @@ -2926,7 +2944,8 @@ already walks Image Trace. Short agent entry points: `ui.actionSet.duplicate {set?: index, action?: index}` copies the action at `action` in set `set` to right after it in the same set, and returns `{set, action}`; without `action` it copies the set with all its actions to right after it, and returns `{set}`. In both cases `set` defaults to the current set. The copy is named after the original with - " copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error. The + " copy" added, a copied set gets an id of its own, and the copy is selected. An index past the last set or action is an error, + and so is a copy that would give its set more than 1,000 actions or a file larger than 8 MiB; then nothing is copied. The panel menu's Duplicate runs this for the selected action, else the current set. - Image Trace: `imageTrace.presets`, `imageTrace.make {id?, preset?, params?, view?}` (or `imageTrace.makeAndExpand`), `imageTrace.setView`, `imageTrace.expand`, `imageTrace.release`. A key inside `params` that is not one of the trace diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d98bb46575..e30f6e9070 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -725,6 +725,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. + - Actions panel: an action keeps to the limits that Load Actions… reads while it records and while its steps are excluded: at most 10,000 steps in an action and 8 MiB in its set's file, which are VectorCraft's own rules. Commands add steps to a recording until it stops ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html)); a command whose step would pass a limit does not run, its document stays as it was, and the recording goes on. A stop that playback reaches is not recorded then, and the status bar shows the reason. Clearing a checkmark keeps the step in the action and skips it in playback ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html)); an exclusion that would make the set's file larger than 8 MiB changes no checkmark, and one that makes the file smaller always works. Stop Playing/Recording leaves out any recorded steps still past a limit and names the limit in the status bar. Begin Recording, Duplicate, New Action, renaming an action, Action Options…, Set Options…, New Set… and Load Actions… (`.aia` sets included) also refuse a change that would give a set more than 1,000 actions or a file larger than 8 MiB, and change nothing then. Tests `panels::actions::tests::{recorded_commands_keep_an_action_at_10_000_steps, recorded_commands_keep_an_action_set_file_within_8_mib, a_recorded_command_whose_noted_values_do_not_fit_is_left_out, a_stop_reached_while_recording_a_full_action_is_left_out, stop_leaves_out_recorded_steps_past_the_limits, excluding_steps_keeps_an_action_set_file_within_8_mib, a_recorded_batch_that_edits_another_document_is_left_out_when_it_grows, a_set_renamed_while_it_records_is_measured_again, steps_recorded_after_an_exclusion_past_the_last_step_are_included, duplicate_keeps_action_set_files_within_their_limits, new_action_keeps_an_action_set_file_within_its_limits, renaming_keeps_an_action_set_file_within_8_mib, load_actions_refuses_a_set_that_would_save_larger_than_8_mib}`. - Actions panel: the panel menu's Playback Options… sets how actions play: Accelerated (the default) plays every step at once, Step by Step plays one step at a time with the canvas drawn after each, and Pause For waits the given number of seconds between steps ([Specify playback speed](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/specify-playback-speed.html); `ui.action.playbackOptions`). VectorCraft's own rules, which the page does not cover: the pause is a whole number of seconds from 1 to 60, the setting is kept with the UI preferences, a paced playback ends at Stop Playing/Recording or when a step comes due while another document is active, each step of a paced playback is its own undo step, and Batch… plays at full speed. - Actions panel: the panel menu's Insert Stop… opens Record Stop, with a Message box and Allow Continue, and adds a stop to the recording in progress, after the selected command, or at the end of the selected action ([Insert stops in actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-stops-in-actions.html); `ui.action.insertStop`). A playing action waits at the stop and shows the message so the user can do a manual task, and Play Current Selection plays the rest ([Illustrator CS5 help](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=459); `ui.action.continue`). With Allow Continue, the message box also has Continue. VectorCraft's own rules, which the pages do not cover: an empty message still waits, Stop Playing/Recording ends the playback and keeps the steps already played, the rest plays on the document active then, a stop waits at every playback speed, at Accelerated the steps between two stops are one undo step, Batch… refuses an action with an included stop, and Delete Selection on a selected step deletes that step, a stop or a command, without asking (`ui.action.deleteStep`). - Actions panel: the panel menu's Insert Menu Item… adds a menu command to the recording in progress, after the selected step, or at the end of the selected action, without running it, and playback runs it as the menu does ([Insert non-recordable tasks into actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/insert-non-recordable-tasks-into-actions.html); `ui.action.insertMenuItem`). The dialog's Find box picks the one item that matches the words typed, and a click on a listed item picks it. VectorCraft's own rules, which the page does not cover: items that open a dialog, the Object › Align items, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern, and the File, Window and Help menu items are not listed, and an action holds at most 10,000 steps and its set file at most 8 MiB, for stops too.