mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-07 12:13:10 +00:00
Guides: integrate edit-session previews and interaction-check scenarios (#9190)
* feat(ui): integrate edit-session guide drafts and interaction-check scenarios * fix(ui): repaint guide previews after automation field changes * fix(guides): retain drafts after command refusal * Restore the upstream Git-source check and keep guide replay local * Document pending source approval and shared replay workflow * Clarify that shared CI support for guide replays is pending * Vendor the guide edit dependency for source review Keep the edit-session revision and runtime behavior. Track its source, licenses, checksums and lifecycle tests in-tree in response to the source review in #9190. Apply the workspace lint policy to the standalone crate. * Move edit-session into ui-egui as a module (drop vendor/) The edit-session code now lives in crates/ui-egui/src/edit_session.rs, with its tests in edit_session/tests.rs, following the project's module layout. This removes vendor/edit-session, the path dependency, the workspace member, the NOTICE entry and the xtask layers entry. --------- Co-authored-by: Anton Pepperok <perchneko.anton@gmail.com> Co-authored-by: Perchenko Anton <perchenkoanton@iMac-Perchenko.local> Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
co-authored by
Anton Pepperok
Perchenko Anton
FredApps
parent
a38e544faf
commit
a3ea9a4277
@@ -0,0 +1,139 @@
|
||||
//! JSONL adapter for interaction-check. Drives the shipped guide dialog and egui frame path.
|
||||
//! See docs/guide-edit-interactions.md for the pinned runner and scenario command.
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use photocraft_ui_egui::{PhotocraftApp, Services, canvas, control, dialogs, guide_select, new_guide_ui};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct Driver {
|
||||
app: PhotocraftApp,
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Driver {
|
||||
/// Create a fresh synthetic document and two guides for each independent scenario.
|
||||
fn new() -> Result<Self, String> {
|
||||
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
|
||||
app.run("file.new", json!({"width":400,"height":200,"resolution":100}))?;
|
||||
app.run("prefs.set", json!({"path":"unitsAndRulers.rulers","value":"pixels"}))?;
|
||||
app.run("view.newGuide", json!({"orientation":"vertical","position":10}))?;
|
||||
app.run("view.newGuide", json!({"orientation":"vertical","position":20}))?;
|
||||
let ctx = egui::Context::default();
|
||||
PhotocraftApp::setup_context(&ctx, Default::default());
|
||||
let mut driver = Self { app, ctx, time: 0.0 };
|
||||
driver.frames(false);
|
||||
Ok(driver)
|
||||
}
|
||||
|
||||
/// Run real egui canvas and dialog frames, optionally delivering Escape on the first.
|
||||
fn frames(&mut self, escape: bool) {
|
||||
self.app.sync_views();
|
||||
for frame in 0..3 {
|
||||
self.time += 1.0 / 60.0;
|
||||
let mut input = egui::RawInput {
|
||||
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0))),
|
||||
time: Some(self.time),
|
||||
..Default::default()
|
||||
};
|
||||
if escape && frame == 0 {
|
||||
input.events.push(egui::Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
}
|
||||
let mut output = self.ctx.run_ui(input, |ui| {
|
||||
let ctx = ui.ctx().clone();
|
||||
egui::CentralPanel::default().show(ui, |ui| canvas::document_area(&mut self.app, ui));
|
||||
dialogs::show(&mut self.app, &ctx);
|
||||
});
|
||||
// Semantic checks do not upload textures; screenshot checks use the snapshot example.
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate the current guide dialog, returning an error after it has closed.
|
||||
fn dialog(&self) -> Result<u64, String> {
|
||||
self.app.ui.dialogs.iter().find(|d| d.fields.contains_key("__edit")).map(|d| d.id).ok_or("no guide dialog".into())
|
||||
}
|
||||
|
||||
/// Dispatch one scenario event through the shipped UI or engine paths.
|
||||
fn event(&mut self, event: &Value) -> Result<(), String> {
|
||||
match event.get("op").and_then(Value::as_str) {
|
||||
Some("begin") => {
|
||||
guide_select::select(&mut self.app, true, 0);
|
||||
new_guide_ui::open_edit(&mut self.app)?;
|
||||
}
|
||||
Some("set") => {
|
||||
let params = json!({"dialog":self.dialog()?,"field":event.get("field"),"value":event.get("value")});
|
||||
let (req, _rx) = control::ControlRequest::new("ui.dialog.set", params);
|
||||
match control::handle(&mut self.app, &self.ctx, &req) {
|
||||
control::Outcome::Done(v) if v.get("ok") == Some(&json!(true)) => {}
|
||||
_ => return Err("ui.dialog.set failed".into()),
|
||||
}
|
||||
}
|
||||
Some("confirm") => {
|
||||
let id = self.dialog()?;
|
||||
dialogs::confirm(&mut self.app, id)?;
|
||||
}
|
||||
Some("cancel") => {
|
||||
let id = self.dialog()?;
|
||||
dialogs::cancel(&mut self.app, id)?;
|
||||
}
|
||||
Some("escape") => self.frames(true),
|
||||
Some("command") => {
|
||||
let id = event.get("id").and_then(Value::as_str).ok_or("command needs id")?;
|
||||
self.app.run(id, event.get("params").cloned().unwrap_or(json!({})))?;
|
||||
}
|
||||
Some("undo") if self.app.session.undo() => {}
|
||||
Some("redo") if self.app.session.redo() => {}
|
||||
Some("other_document") => {
|
||||
self.app.run("file.new", json!({"width":400,"height":200}))?;
|
||||
self.app.run("view.newGuide", json!({"orientation":"vertical","position":10}))?;
|
||||
}
|
||||
_ => return Err("unknown or unavailable event".into()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Report observable document, preview, dialog and history state after an event.
|
||||
fn state(&self, result: Result<(), String>) -> Value {
|
||||
let active = self.app.session.active();
|
||||
let preview = active.and_then(|st| new_guide_ui::edit_preview(&self.app, &st.doc));
|
||||
let dialog = self.app.ui.dialogs.iter().find(|d| d.fields.contains_key("__edit"));
|
||||
json!({
|
||||
"rejected": result.is_err(), "error": result.err(),
|
||||
"vertical": active.map(|st| &st.doc.guides.vertical),
|
||||
"horizontal": active.map(|st| &st.doc.guides.horizontal),
|
||||
"colors": active.map(|st| &st.doc.guides.colors),
|
||||
"preview": preview,
|
||||
"history": active.map(|st| st.history.past_len()),
|
||||
"redo": active.is_some_and(|st| st.history.can_redo()),
|
||||
"editing": dialog.is_some(),
|
||||
"invalid": dialog.is_some_and(|d| d.fields.contains_key("__error")),
|
||||
"editCommands": self.app.session.journal.iter().filter(|(id, _)| id == "view.editGuide").count(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Exchange one JSONL response per validated protocol request until stdin closes.
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut driver = Driver::new()?;
|
||||
let mut out = io::BufWriter::new(io::stdout().lock());
|
||||
for line in io::stdin().lock().lines() {
|
||||
let request: Value = serde_json::from_str(&line?)?;
|
||||
if request.get("protocol") != Some(&json!(1)) || request.get("id").and_then(Value::as_u64).is_none() {
|
||||
return Err("expected interaction-check protocol 1 and an unsigned id".into());
|
||||
}
|
||||
let event = request.get("event").ok_or("missing event")?;
|
||||
let result = driver.event(event);
|
||||
driver.frames(false);
|
||||
let response = json!({"protocol":1,"id":request.get("id"),"state":driver.state(result)});
|
||||
writeln!(out, "{response}")?;
|
||||
out.flush()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -114,6 +114,7 @@ impl PhotocraftApp {
|
||||
proxy: None,
|
||||
proxy_uploaded: None,
|
||||
outline_cache: None,
|
||||
guide_edit: None,
|
||||
filter_preview: None,
|
||||
smart_filter_targets: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1308,7 +1308,10 @@ fn paint_primary_overlays(app: &mut PhotocraftApp, ui: &mut egui::Ui, view: &mut
|
||||
if app.ui.extras.grid && app.ui.view.extras {
|
||||
crate::rulers::draw_grid(app, painter, xf, doc);
|
||||
}
|
||||
if app.ui.view.shows(app.ui.view.show.canvas_guides) || crate::guide_layout_ui::preview(app, doc).is_some() {
|
||||
if app.ui.view.shows(app.ui.view.show.canvas_guides)
|
||||
|| crate::guide_layout_ui::preview(app, doc).is_some()
|
||||
|| crate::new_guide_ui::edit_preview(app, doc).is_some()
|
||||
{
|
||||
crate::rulers::draw_guides(app, painter, xf, doc);
|
||||
}
|
||||
// The selection's ants go over the grid and guides, as in Photoshop (#10671).
|
||||
|
||||
@@ -216,7 +216,8 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
|
||||
let drag = app.guide_drag;
|
||||
let g = &app.session.prefs().guides_grid_and_slices;
|
||||
let preview = crate::guide_layout_ui::preview(app, doc);
|
||||
let guides = preview.as_ref().map(|(guides, _)| guides).unwrap_or(&doc.guides);
|
||||
let edit = crate::new_guide_ui::edit_preview(app, doc);
|
||||
let guides = edit.or_else(|| preview.as_ref().map(|(guides, _)| guides)).unwrap_or(&doc.guides);
|
||||
let (guide, style) =
|
||||
(preview.as_ref().map(|(_, color)| *color).unwrap_or_else(|| pref_color(&g.guide_color, Color32::from_rgb(74, 255, 255))), g.guide_style);
|
||||
let line = |vertical: bool, pos: f64, color: Color32| {
|
||||
@@ -226,12 +227,12 @@ pub fn draw_guides(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform,
|
||||
// A guide given a colour in New Guide keeps it; the rest use the preference colour (or, while
|
||||
// New Guide Layout previews, the colour it will set). The selected guide is Photoshop's blue.
|
||||
let own = |vertical: bool, p: f64| {
|
||||
if crate::guide_select::is_selected(app, vertical, p) {
|
||||
if edit.is_none() && crate::guide_select::is_selected(app, vertical, p) {
|
||||
return crate::guide_select::SELECTED_COLOR;
|
||||
}
|
||||
guides.color(vertical, p).map_or(guide, |[r, g, b]| Color32::from_rgb(r, g, b))
|
||||
};
|
||||
if app.ui.extras.guides || preview.is_some() {
|
||||
if app.ui.extras.guides || preview.is_some() || edit.is_some() {
|
||||
for (vertical, list) in [(true, &guides.vertical), (false, &guides.horizontal)] {
|
||||
for (i, p) in list.iter().enumerate() {
|
||||
if drag.is_some_and(|d| d.vertical == vertical && d.index == Some(i)) {
|
||||
|
||||
@@ -554,6 +554,9 @@ fn default_max_width(d: &Dialog, compact: bool, curves: bool, about_tabs: bool,
|
||||
}
|
||||
|
||||
pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
if crate::guide_edit::sync(app) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
crate::layer_style::color_picker::prune(app);
|
||||
crate::type_panels_ui::color_picker::prune(app);
|
||||
crate::edit_history::track_dialogs(app, ctx);
|
||||
@@ -768,6 +771,9 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if crate::guide_edit::sync(app) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
ctx.data_mut(|m| m.insert_temp(egui::Id::unique(RECTS), shown));
|
||||
// A colour picked this frame shows in its target dialog on the next one.
|
||||
if crate::color_picker_ui::live_preview::sync(app) {
|
||||
@@ -1078,6 +1084,7 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
||||
/// Cancel a dialog and its dependent Layer Style picker, without applying edits.
|
||||
pub fn cancel(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
|
||||
let closed = app.ui.close_dialog(id).ok_or_else(|| format!("no dialog {id}"))?;
|
||||
crate::guide_edit::cancel(app, id);
|
||||
// A Color Picker puts back what its live preview showed in its target.
|
||||
crate::color_picker_ui::live_preview::end(app, id);
|
||||
// A field Color Picker of this dialog has no target left: it closes with the dialog.
|
||||
@@ -1188,6 +1195,7 @@ pub(super) mod filter_preview_ui;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) mod filter_preview_worker;
|
||||
pub mod gallery_ui;
|
||||
pub(crate) mod guide_edit;
|
||||
pub mod guide_layout_ui;
|
||||
pub mod history_options_ui;
|
||||
pub mod jpeg_options_ui;
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! A guide dialog's draft. Rendering and command validation stay in PhotoCraft; the
|
||||
//! [`edit_session`](crate::edit_session) module owns proposal validity, stale-context rejection
|
||||
//! and the single-use/no-op commit decision.
|
||||
use crate::edit_session::{EditSession, FinishError};
|
||||
use photocraft_doc::{DocId, Document, Guides};
|
||||
use photocraft_engine::prefs::{PointSize, Unit};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::PhotocraftApp;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Guide {
|
||||
vertical: bool,
|
||||
position: f64,
|
||||
color: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Context {
|
||||
doc: DocId,
|
||||
revision: u64,
|
||||
rulers: Unit,
|
||||
points: PointSize,
|
||||
locked: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct Draft {
|
||||
pub(crate) dialog: u64,
|
||||
target: Value,
|
||||
initial_position: String,
|
||||
edit: EditSession<Guide, Context>,
|
||||
params: Option<Value>,
|
||||
guides: Option<Guides>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// Identity, revision, units and lock state that must still match at confirmation.
|
||||
fn context(app: &PhotocraftApp) -> Option<Context> {
|
||||
let st = app.session.active()?;
|
||||
let units = &app.session.prefs().units_and_rulers;
|
||||
Some(Context { doc: st.doc.id, revision: st.revision, rulers: units.rulers, points: units.point_size, locked: app.ui.extras.lock_guides })
|
||||
}
|
||||
|
||||
/// Read a guide and its explicit colour without trusting a saved index.
|
||||
fn guide(g: &Guides, vertical: bool, index: usize) -> Option<Guide> {
|
||||
let position = *if vertical { &g.vertical } else { &g.horizontal }.get(index)?;
|
||||
Some(Guide { vertical, position, color: g.color(vertical, position) })
|
||||
}
|
||||
|
||||
/// Decode a target only when its orientation and index are representable.
|
||||
fn target(f: &Value) -> Option<(bool, usize)> {
|
||||
let vertical = match f.get("orientation")?.as_str()? {
|
||||
"vertical" => true,
|
||||
"horizontal" => false,
|
||||
_ => return None,
|
||||
};
|
||||
Some((vertical, usize::try_from(f.get("index")?.as_u64()?).ok()?))
|
||||
}
|
||||
|
||||
/// Capture the original target and context, retiring any previous guide dialog.
|
||||
pub(crate) fn begin(app: &mut PhotocraftApp, dialog: u64, fields: &Map<String, Value>) -> Result<(), String> {
|
||||
let target_value = fields.get("__edit").ok_or("missing guide target")?;
|
||||
let (vertical, index) = target(target_value).ok_or("invalid guide target")?;
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let value = guide(&st.doc.guides, vertical, index).ok_or("no such guide")?;
|
||||
let context = context(app).ok_or("no document")?;
|
||||
if let Some(previous) = app.guide_edit.take() {
|
||||
app.ui.close_dialog(previous.dialog);
|
||||
previous.edit.cancel();
|
||||
}
|
||||
app.guide_edit = Some(Draft {
|
||||
dialog,
|
||||
target: target_value.clone(),
|
||||
initial_position: fields.get("position").and_then(Value::as_str).unwrap_or_default().into(),
|
||||
edit: EditSession::new(value, context),
|
||||
params: None,
|
||||
guides: None,
|
||||
error: None,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the live target for comparison with the draft's original context.
|
||||
fn current(app: &PhotocraftApp, draft: &Draft) -> Option<(Guide, Context)> {
|
||||
let (vertical, index) = target(&draft.target)?;
|
||||
Some((guide(&app.session.active()?.doc.guides, vertical, index)?, context(app)?))
|
||||
}
|
||||
|
||||
/// Invalid text remains editable; a changed target or context requires a new draft.
|
||||
fn stale(app: &PhotocraftApp, draft: &Draft) -> bool {
|
||||
current(app, draft).is_none_or(|(live, ctx)| !matches!(draft.edit.check(&live, &ctx), Ok(()) | Err(FinishError::InvalidProposal)))
|
||||
}
|
||||
|
||||
/// Allow retry only while the retained draft still belongs to the same live guide.
|
||||
pub(crate) fn retryable(app: &PhotocraftApp) -> bool {
|
||||
app.guide_edit.as_ref().is_some_and(|d| !stale(app, d))
|
||||
}
|
||||
|
||||
/// Validate changed parameters on a COW document, retaining the last valid preview on error.
|
||||
fn prepare(app: &PhotocraftApp, draft: &mut Draft, fields: &Map<String, Value>) -> Result<(), String> {
|
||||
if stale(app, draft) || fields.get("__edit") != Some(&draft.target) {
|
||||
return Err("The document or guide changed. Reopen Edit Selected Guides.".into());
|
||||
}
|
||||
let mut params = crate::new_guide_ui::params(app, fields);
|
||||
// Opening a fractional position in cm/percent rounds its display. Leaving the text and
|
||||
// orientation alone must not move the guide or consume Redo through that rounding.
|
||||
if fields.get("position").and_then(Value::as_str) == Some(&draft.initial_position)
|
||||
&& (params.get("newOrientation").and_then(Value::as_str) == Some("vertical")) == draft.edit.original().vertical
|
||||
{
|
||||
params["position"] = serde_json::json!(draft.edit.original().position);
|
||||
}
|
||||
if draft.params.as_ref() == Some(¶ms) {
|
||||
return draft.error.clone().map_or(Ok(()), Err);
|
||||
}
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
// A cheap COW document on parameter changes only. The existing engine owns units,
|
||||
// validation, colour inheritance and turning a guide into the other orientation.
|
||||
let mut preview = photocraft_engine::Session::new();
|
||||
preview.prefs = app.session.prefs.clone();
|
||||
preview.add_document((*st.doc).clone(), None);
|
||||
let result = preview.execute("view.editGuide", params.clone()).map_err(|e| e.to_string()).and_then(|_| {
|
||||
let g = &preview.active().ok_or("no preview document")?.doc.guides;
|
||||
let (from, old_index) = target(&draft.target).ok_or("invalid guide target")?;
|
||||
let to = params.get("newOrientation").and_then(Value::as_str) == Some("vertical");
|
||||
let index = if from == to { old_index } else { if to { g.vertical.len() } else { g.horizontal.len() }.checked_sub(1).ok_or("no preview guide")? };
|
||||
let value = guide(g, to, index).ok_or("no preview guide")?;
|
||||
draft.guides = Some(g.clone());
|
||||
Ok(value)
|
||||
});
|
||||
draft.params = Some(params);
|
||||
draft.edit.propose(result)
|
||||
}
|
||||
|
||||
/// Keep the draft and its validation message available after either valid or invalid input.
|
||||
pub(crate) fn update(app: &mut PhotocraftApp, fields: &Map<String, Value>) -> Result<(), String> {
|
||||
let mut draft = app.guide_edit.take().ok_or("Reopen Edit Selected Guides to start an edit.")?;
|
||||
let result = prepare(app, &mut draft, fields);
|
||||
draft.error = result.as_ref().err().cloned();
|
||||
app.guide_edit = Some(draft);
|
||||
result
|
||||
}
|
||||
|
||||
/// Commit once through the ordinary command path; a no-op preserves Redo.
|
||||
/// A command refusal restores the proposal against its original context for a guarded retry.
|
||||
pub(crate) fn finish(app: &mut PhotocraftApp, fields: &Map<String, Value>) -> Result<Option<(Value, Value)>, String> {
|
||||
update(app, fields)?;
|
||||
let mut draft = app.guide_edit.take().ok_or("no guide draft")?;
|
||||
let (live, ctx) = current(app, &draft).ok_or("the guide is gone")?;
|
||||
let change = draft.edit.finish(&live, &ctx).map_err(|_| "The document or guide changed. Reopen Edit Selected Guides.")?;
|
||||
let Some(change) = change else { return Ok(None) };
|
||||
let params = draft.params.clone().ok_or("no guide parameters")?;
|
||||
match app.run("view.editGuide", params.clone()) {
|
||||
Ok(result) => Ok(Some((params, result))),
|
||||
Err(error) => {
|
||||
// Reuse the pre-command context, never a fresh document/revision. The caller's
|
||||
// retryable check rejects this draft if the failed command changed that context.
|
||||
draft.edit = EditSession::new(live, ctx);
|
||||
draft.edit.propose::<String>(Ok(change.after))?;
|
||||
app.guide_edit = Some(draft);
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard only the draft owned by the closing dialog, without touching history.
|
||||
pub(crate) fn cancel(app: &mut PhotocraftApp, id: u64) {
|
||||
if app.guide_edit.as_ref().is_some_and(|d| d.dialog == id)
|
||||
&& let Some(draft) = app.guide_edit.take()
|
||||
{
|
||||
draft.edit.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh after dialog fields change, and drop drafts whose dialog has gone away.
|
||||
pub(crate) fn sync(app: &mut PhotocraftApp) -> bool {
|
||||
let Some(id) = app.guide_edit.as_ref().map(|d| d.dialog) else { return false };
|
||||
let Some(fields) = app.ui.dialogs.iter().find(|d| d.id == id).map(|d| d.fields.clone()) else {
|
||||
cancel(app, id);
|
||||
return false;
|
||||
};
|
||||
let old = app.guide_edit.as_ref().and_then(|d| d.params.clone());
|
||||
let result = update(app, &fields);
|
||||
let changed = old != app.guide_edit.as_ref().and_then(|d| d.params.clone());
|
||||
if let Some(d) = app.ui.dialog_mut(id) {
|
||||
match result {
|
||||
Ok(()) if changed => {
|
||||
d.fields.remove("__error");
|
||||
}
|
||||
// Unchanged, valid parameters may still have a command-policy refusal to show.
|
||||
Ok(()) => {}
|
||||
Err(error) => {
|
||||
d.fields.insert("__error".into(), Value::String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// Expose the last valid preview only for its unchanged document and still-open dialog.
|
||||
pub(crate) fn preview<'a>(app: &'a PhotocraftApp, doc: &Document) -> Option<&'a Guides> {
|
||||
let draft = app.guide_edit.as_ref()?;
|
||||
if draft.edit.context().doc != doc.id || stale(app, draft) || !app.ui.dialogs.iter().any(|d| d.id == draft.dialog) {
|
||||
return None;
|
||||
}
|
||||
draft.guides.as_ref()
|
||||
}
|
||||
@@ -53,6 +53,7 @@ pub(crate) fn color_label(name: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept the named guide colours and the explicit custom-colour choice.
|
||||
fn is_color_name(name: &str) -> bool {
|
||||
name == CUSTOM || GUIDE_COLORS.iter().any(|(n, ..)| *n == name)
|
||||
}
|
||||
@@ -65,10 +66,12 @@ fn custom_color(f: &Map<String, Value>) -> String {
|
||||
.map_or_else(|| "#4affff".to_string(), str::to_string)
|
||||
}
|
||||
|
||||
/// Identify the specialized New Guide or Edit Selected Guides dialog fields.
|
||||
pub fn owns(fields: &Map<String, Value>) -> bool {
|
||||
fields.contains_key(MARK)
|
||||
}
|
||||
|
||||
/// Read the ruler unit used for guide positions without an explicit suffix.
|
||||
fn ruler_unit(app: &PhotocraftApp) -> Unit {
|
||||
app.session.prefs().units_and_rulers.rulers
|
||||
}
|
||||
@@ -145,9 +148,15 @@ pub fn open_edit(app: &mut PhotocraftApp) -> Result<u64, String> {
|
||||
f.insert(INITIAL_COLOR.into(), json!(if color == CUSTOM { custom.clone() } else { color.clone() }));
|
||||
f.insert("color".into(), json!(color));
|
||||
f.insert("customColor".into(), json!(custom));
|
||||
Ok(app.ui.open_dialog(DialogKind::Command, f))
|
||||
let id = app.ui.open_dialog(DialogKind::Command, f.clone());
|
||||
if let Err(error) = crate::guide_edit::begin(app, id, &f) {
|
||||
app.ui.close_dialog(id);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Edit the dialog fields and show validation errors without changing the live guide.
|
||||
pub fn body(ui: &mut egui::Ui, f: &mut Map<String, Value>) {
|
||||
let mut orientation = f.get("orientation").and_then(Value::as_str).unwrap_or("horizontal").to_string();
|
||||
let mut position = f.get("position").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
@@ -212,10 +221,11 @@ pub fn params(app: &PhotocraftApp, f: &Map<String, Value>) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch New Guide or guarded guide editing, retaining typed fields on a retryable error.
|
||||
pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
let params = params(app, f);
|
||||
if f.contains_key(EDIT) {
|
||||
return confirm_edit(app, f, ¶ms);
|
||||
return confirm_edit(app, f);
|
||||
}
|
||||
match app.run(COMMAND, params.clone()) {
|
||||
Ok(result) => {
|
||||
@@ -244,27 +254,39 @@ pub fn confirm(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value,
|
||||
|
||||
/// Edit Selected Guides' OK: the edited guide stays selected (at the end of the other list if it
|
||||
/// turned). A refused edit reopens the dialog, as New Guide does.
|
||||
fn confirm_edit(app: &mut PhotocraftApp, f: &Map<String, Value>, params: &Value) -> Result<Value, String> {
|
||||
match app.run(EDIT_COMMAND, params.clone()) {
|
||||
Ok(result) => {
|
||||
let (from, to) = (params["orientation"] == "vertical", params["newOrientation"] == "vertical");
|
||||
let index = if from == to {
|
||||
params["index"].as_u64().and_then(|i| usize::try_from(i).ok())
|
||||
fn confirm_edit(app: &mut PhotocraftApp, f: &Map<String, Value>) -> Result<Value, String> {
|
||||
let (params, result) = match crate::guide_edit::finish(app, f) {
|
||||
Ok(Some(applied)) => applied,
|
||||
Ok(None) => return Ok(Value::Null),
|
||||
Err(error) => {
|
||||
if crate::guide_edit::retryable(app) {
|
||||
let mut again = f.clone();
|
||||
again.insert("__error".into(), json!(error));
|
||||
let id = app.ui.open_dialog(DialogKind::Command, again);
|
||||
if let Some(draft) = app.guide_edit.as_mut() {
|
||||
draft.dialog = id;
|
||||
}
|
||||
} else {
|
||||
app.session.active().map(|st| if to { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }.saturating_sub(1))
|
||||
};
|
||||
if let Some(i) = index {
|
||||
crate::guide_select::select(app, to, i);
|
||||
app.guide_edit = None;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
Err(e) => {
|
||||
let mut again = f.clone();
|
||||
again.insert("__error".into(), json!(e));
|
||||
app.ui.open_dialog(DialogKind::Command, again);
|
||||
Err(e)
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let (from, to) = (params["orientation"] == "vertical", params["newOrientation"] == "vertical");
|
||||
let index = if from == to {
|
||||
params["index"].as_u64().and_then(|i| usize::try_from(i).ok())
|
||||
} else {
|
||||
app.session.active().map(|st| if to { st.doc.guides.vertical.len() } else { st.doc.guides.horizontal.len() }.saturating_sub(1))
|
||||
};
|
||||
if let Some(i) = index {
|
||||
crate::guide_select::select(app, to, i);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The validated draft shown on the canvas, without changing the document or its history.
|
||||
pub fn edit_preview<'a>(app: &'a PhotocraftApp, doc: &photocraft_doc::Document) -> Option<&'a photocraft_doc::Guides> {
|
||||
crate::guide_edit::preview(app, doc)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -12,21 +12,25 @@ fn app(unit: &str) -> (PhotocraftApp, egui::Context) {
|
||||
(app, egui::Context::default())
|
||||
}
|
||||
|
||||
/// Open New Guide through the same menu entry used by the shell.
|
||||
fn open(app: &mut PhotocraftApp, ctx: &egui::Context) -> u64 {
|
||||
menu(app, ctx, "view.newGuide", json!({})).unwrap()["dialog"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// Change the typed orientation and position without confirming the dialog.
|
||||
fn set(app: &mut PhotocraftApp, d: u64, orientation: &str, position: &str) {
|
||||
let f = &mut app.ui.dialog_mut(d).unwrap().fields;
|
||||
f.insert("orientation".into(), json!(orientation));
|
||||
f.insert("position".into(), json!(position));
|
||||
}
|
||||
|
||||
/// Read committed guide positions, excluding any local draft preview.
|
||||
fn guides(app: &PhotocraftApp) -> (Vec<f64>, Vec<f64>) {
|
||||
let g = &app.session.active().unwrap().doc.guides;
|
||||
(g.horizontal.clone(), g.vertical.clone())
|
||||
}
|
||||
|
||||
/// Guide positions accept explicit units and reopen in the current ruler unit.
|
||||
#[test]
|
||||
fn opens_in_the_ruler_unit_and_takes_photoshop_units() {
|
||||
let (mut app, ctx) = app("cm");
|
||||
@@ -53,6 +57,7 @@ fn opens_in_the_ruler_unit_and_takes_photoshop_units() {
|
||||
assert_eq!(guides(&app).0, vec![100.0, 50.0]);
|
||||
}
|
||||
|
||||
/// Bare positions use percent when the ruler unit is percent.
|
||||
#[test]
|
||||
fn percent_rulers_open_at_zero_percent() {
|
||||
let (mut app, ctx) = app("percent");
|
||||
@@ -63,6 +68,7 @@ fn percent_rulers_open_at_zero_percent() {
|
||||
assert_eq!(guides(&app).0, vec![50.0], "25% of the 200 px height");
|
||||
}
|
||||
|
||||
/// Invalid New Guide input is retained without recording a guide or preferences.
|
||||
#[test]
|
||||
fn a_refused_position_reopens_the_dialog_without_a_guide() {
|
||||
let (mut app, ctx) = app("pixels");
|
||||
@@ -79,6 +85,7 @@ fn a_refused_position_reopens_the_dialog_without_a_guide() {
|
||||
assert!(!app.session.prefs().dialogs.contains_key("view.newGuide"));
|
||||
}
|
||||
|
||||
/// Invalid saved orientation and position fall back to a usable new-guide dialog.
|
||||
#[test]
|
||||
fn remembered_values_that_no_longer_parse_are_ignored() {
|
||||
let (mut app, ctx) = app("mm");
|
||||
@@ -88,10 +95,12 @@ fn remembered_values_that_no_longer_parse_are_ignored() {
|
||||
assert_eq!((f["orientation"].as_str(), f["position"].as_str()), (Some("horizontal"), Some("0 mm")));
|
||||
}
|
||||
|
||||
/// Read the colour stored on a committed guide.
|
||||
fn color_of(app: &PhotocraftApp, vertical: bool, position: f64) -> Option<[u8; 3]> {
|
||||
app.session.active().unwrap().doc.guides.color(vertical, position)
|
||||
}
|
||||
|
||||
/// Named and custom guide colours persist on the guide and in dialog preferences.
|
||||
#[test]
|
||||
fn guides_store_the_chosen_colour_cyan_by_default() {
|
||||
let (mut app, ctx) = app("pixels");
|
||||
@@ -120,6 +129,7 @@ fn guides_store_the_chosen_colour_cyan_by_default() {
|
||||
assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("custom"), Some("#abcdef")));
|
||||
}
|
||||
|
||||
/// Invalid saved colours fall back to the default cyan choice.
|
||||
#[test]
|
||||
fn remembered_colours_that_are_not_on_the_list_are_ignored() {
|
||||
let (mut app, ctx) = app("pixels");
|
||||
@@ -129,6 +139,7 @@ fn remembered_colours_that_are_not_on_the_list_are_ignored() {
|
||||
assert_eq!((f["color"].as_str(), f["customColor"].as_str()), (Some("cyan"), Some("#4affff")));
|
||||
}
|
||||
|
||||
/// A remembered pixel position is displayed in each current ruler unit.
|
||||
#[test]
|
||||
fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() {
|
||||
let (mut app, ctx) = app("pixels");
|
||||
@@ -142,3 +153,187 @@ fn the_last_position_reopens_in_whatever_the_ruler_unit_is_now() {
|
||||
crate::dialogs::cancel(&mut app, d).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// These regressions exercise the ordinary dialog handlers, not a separate model.
|
||||
/// Open a draft on the first of two guides and propose moving it to 30 px.
|
||||
fn edit_fixture() -> (PhotocraftApp, u64) {
|
||||
let (mut app, _) = app("pixels");
|
||||
app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
|
||||
app.run("view.newGuide", json!({"orientation": "vertical", "position": 20})).unwrap();
|
||||
crate::guide_select::select(&mut app, true, 0);
|
||||
let id = open_edit(&mut app).unwrap();
|
||||
set(&mut app, id, "vertical", "30 px");
|
||||
(app, id)
|
||||
}
|
||||
|
||||
/// A document switch rejects the old draft without touching the new document.
|
||||
#[test]
|
||||
fn draft_cannot_edit_a_guide_in_another_document() {
|
||||
let (mut app, id) = edit_fixture();
|
||||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert_eq!(guides(&app).1, vec![10.0]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
|
||||
/// Deleting the target invalidates its draft even when another guide reuses the index.
|
||||
#[test]
|
||||
fn draft_cannot_edit_the_guide_that_reused_a_deleted_index() {
|
||||
let (mut app, id) = edit_fixture();
|
||||
app.run("view.deleteGuide", json!({"orientation": "vertical", "index": 0})).unwrap();
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert_eq!(guides(&app).1, vec![20.0]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
}
|
||||
|
||||
/// Confirming unchanged values records no command and retains the redo stack.
|
||||
#[test]
|
||||
fn draft_unchanged_confirmation_preserves_redo() {
|
||||
let (mut app, id) = edit_fixture();
|
||||
crate::dialogs::cancel(&mut app, id).unwrap();
|
||||
app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 15})).unwrap();
|
||||
assert!(app.session.undo());
|
||||
crate::guide_select::select(&mut app, true, 0);
|
||||
let id = open_edit(&mut app).unwrap();
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
assert!(app.session.active().unwrap().history.can_redo());
|
||||
}
|
||||
|
||||
/// Preview changes stay local; invalid input can be corrected into exactly one commit.
|
||||
#[test]
|
||||
fn draft_preview_is_isolated_and_invalid_text_keeps_the_last_good_preview() {
|
||||
let (mut app, id) = edit_fixture();
|
||||
crate::guide_edit::sync(&mut app);
|
||||
let shown = |app: &PhotocraftApp| edit_preview(app, &app.session.active().unwrap().doc).unwrap().vertical.clone();
|
||||
assert_eq!(shown(&app), vec![30.0, 20.0]);
|
||||
assert_eq!(guides(&app).1, vec![10.0, 20.0]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), 2);
|
||||
set(&mut app, id, "vertical", "far");
|
||||
crate::guide_edit::sync(&mut app);
|
||||
assert_eq!(shown(&app), vec![30.0, 20.0]);
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
let id = app.ui.dialogs.last().unwrap().id;
|
||||
assert!(app.ui.dialog_mut(id).unwrap().fields.contains_key("__error"));
|
||||
set(&mut app, id, "vertical", "40 px");
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(guides(&app).1, vec![40.0, 20.0]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), 3);
|
||||
assert_eq!(app.session.journal.iter().filter(|(cmd, _)| cmd == "view.editGuide").count(), 1);
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
}
|
||||
|
||||
/// Opening another edit invalidates the earlier dialog; cancellation removes the draft.
|
||||
#[test]
|
||||
fn draft_reopening_retires_the_previous_dialog() {
|
||||
let (mut app, first) = edit_fixture();
|
||||
let second = open_edit(&mut app).unwrap();
|
||||
assert!(crate::dialogs::confirm(&mut app, first).is_err());
|
||||
crate::dialogs::cancel(&mut app, second).unwrap();
|
||||
assert!(app.guide_edit.is_none());
|
||||
assert_eq!(guides(&app).1, vec![10.0, 20.0]);
|
||||
}
|
||||
|
||||
/// Locking guides invalidates an open edit before it can record a command.
|
||||
#[test]
|
||||
fn draft_cannot_commit_after_guides_are_locked() {
|
||||
let (mut app, id) = edit_fixture();
|
||||
app.ui.extras.lock_guides = true;
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert_eq!(guides(&app).1, vec![10.0, 20.0]);
|
||||
assert!(app.guide_edit.is_none());
|
||||
}
|
||||
|
||||
/// A late policy refusal retains the typed proposal and history until a successful retry.
|
||||
#[test]
|
||||
fn refused_edit_command_preserves_the_draft_for_retry() {
|
||||
let (mut app, id) = edit_fixture();
|
||||
crate::dialogs::cancel(&mut app, id).unwrap();
|
||||
app.run("view.moveGuide", json!({"orientation": "vertical", "index": 0, "position": 15})).unwrap();
|
||||
assert!(app.session.undo());
|
||||
crate::guide_select::select(&mut app, true, 0);
|
||||
let id = open_edit(&mut app).unwrap();
|
||||
set(&mut app, id, "vertical", "30 px");
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
let revision = app.session.active().unwrap().revision;
|
||||
app.automation_input = true;
|
||||
app.services.automation_command = Some(Box::new(|id, _| if id == "view.editGuide" { Err("guide edits are denied".into()) } else { Ok(()) }));
|
||||
let mut id = id;
|
||||
for _ in 0..2 {
|
||||
assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "guide edits are denied");
|
||||
assert_eq!(guides(&app).1, vec![10.0, 20.0]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
assert_eq!(app.session.active().unwrap().revision, revision);
|
||||
assert!(app.session.active().unwrap().history.can_redo());
|
||||
assert_eq!(app.session.journal.iter().filter(|(cmd, _)| cmd == "view.editGuide").count(), 0);
|
||||
crate::guide_edit::sync(&mut app);
|
||||
let again = app.ui.dialogs.iter().find(|d| d.fields.contains_key(EDIT)).expect("retry dialog remains open");
|
||||
id = again.id;
|
||||
assert_eq!(again.fields["position"], "30 px");
|
||||
assert_eq!(again.fields["orientation"], "vertical");
|
||||
assert_eq!(again.fields["__error"], "guide edits are denied");
|
||||
assert_eq!(edit_preview(&app, &app.session.active().unwrap().doc).unwrap().vertical, vec![30.0, 20.0]);
|
||||
}
|
||||
app.services.automation_command = None;
|
||||
crate::dialogs::confirm(&mut app, id).unwrap();
|
||||
assert_eq!(guides(&app).1, vec![30.0, 20.0]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history + 1);
|
||||
assert_eq!(app.session.journal.iter().filter(|(cmd, _)| cmd == "view.editGuide").count(), 1);
|
||||
assert!(app.guide_edit.is_none());
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(guides(&app).1, vec![10.0, 20.0]);
|
||||
assert!(app.session.redo());
|
||||
assert_eq!(guides(&app).1, vec![30.0, 20.0]);
|
||||
}
|
||||
|
||||
/// Restoring a refused proposal must not bind it to a different document at retry time.
|
||||
#[test]
|
||||
fn refused_edit_draft_cannot_follow_a_document_switch() {
|
||||
let (mut app, id) = edit_fixture();
|
||||
app.automation_input = true;
|
||||
app.services.automation_command = Some(Box::new(|_, _| Err("denied".into())));
|
||||
assert_eq!(crate::dialogs::confirm(&mut app, id).unwrap_err(), "denied");
|
||||
let id = app.ui.dialogs.last().unwrap().id;
|
||||
app.services.automation_command = None;
|
||||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
app.run("view.newGuide", json!({"orientation": "vertical", "position": 10})).unwrap();
|
||||
let history = app.session.active().unwrap().history.past_len();
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
assert_eq!(guides(&app).1, vec![10.0]);
|
||||
assert_eq!(app.session.active().unwrap().history.past_len(), history);
|
||||
assert!(app.guide_edit.is_none());
|
||||
assert!(!app.ui.dialogs.iter().any(|d| d.fields.contains_key(EDIT)));
|
||||
}
|
||||
|
||||
/// Normal UI frames retain the command refusal; editing the position clears it.
|
||||
#[test]
|
||||
fn refused_edit_shows_the_error_and_typed_fields_in_the_dialog() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (mut app, id) = edit_fixture();
|
||||
app.automation_input = true;
|
||||
app.services.automation_command = Some(Box::new(|_, _| Err("guide edits are denied".into())));
|
||||
assert!(crate::dialogs::confirm(&mut app, id).is_err());
|
||||
let evidence = std::env::var_os("PHOTOCRAFT_GUIDE_RETRY_EVIDENCE");
|
||||
let builder = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0));
|
||||
let builder = if evidence.is_some() { builder.wgpu() } else { builder };
|
||||
let mut h = builder.build_eframe(move |cc| {
|
||||
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
|
||||
app
|
||||
});
|
||||
h.run_steps(3);
|
||||
h.get_by_label("guide edits are denied");
|
||||
assert_eq!(h.get_by_role_and_label(egui::accesskit::Role::TextInput, "Position").value().as_deref(), Some("30 px"));
|
||||
if let Some(path) = evidence {
|
||||
h.render().unwrap().save(std::path::Path::new(&path)).unwrap();
|
||||
}
|
||||
let id = h.state().ui.dialogs.last().unwrap().id;
|
||||
set(h.state_mut(), id, "vertical", "40 px");
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("guide edits are denied").is_none());
|
||||
assert_eq!(guides(h.state()).1, vec![10.0, 20.0]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//! A local draft of a value, independent of rendering and the application's undo engine.
|
||||
//!
|
||||
//! The host validates proposals and owns all document mutations. Dropping a session cancels
|
||||
//! only its local draft. Store small parameters here, not a full document per pointer event.
|
||||
//! A context must include stable document/target identity and a revision that changes on
|
||||
//! every relevant edit (including undo/redo). Comparing values alone cannot detect ABA edits.
|
||||
|
||||
/// Why a draft cannot be committed to the current host state.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FinishError {
|
||||
/// The most recent proposal was rejected. Correct it before finishing.
|
||||
InvalidProposal,
|
||||
/// Document, target, selection or relevant revision has changed.
|
||||
ContextChanged,
|
||||
/// The live value changed without the context being updated.
|
||||
ValueChanged,
|
||||
}
|
||||
|
||||
/// A proposed change. The caller must apply it through its existing command engine.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Change<T> {
|
||||
pub before: T,
|
||||
pub after: T,
|
||||
}
|
||||
|
||||
/// A single-use edit. It never writes to a document or records history itself.
|
||||
///
|
||||
/// This type intentionally does not implement `Clone`. `finish` consumes it, so the same
|
||||
/// session cannot accidentally emit two commits. A failed finish also consumes the draft;
|
||||
/// call `check` first if invalid input should remain editable in the UI.
|
||||
#[must_use = "a draft must be retained, finished, or deliberately cancelled"]
|
||||
#[derive(Debug)]
|
||||
pub struct EditSession<T, C> {
|
||||
original: T,
|
||||
preview: T,
|
||||
context: C,
|
||||
valid: bool,
|
||||
}
|
||||
|
||||
impl<T: Clone, C> EditSession<T, C> {
|
||||
/// Begin from a value already validated by the application.
|
||||
pub fn new(original: T, context: C) -> Self {
|
||||
Self { preview: original.clone(), original, context, valid: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T, C> EditSession<T, C> {
|
||||
pub fn original(&self) -> &T {
|
||||
&self.original
|
||||
}
|
||||
|
||||
/// Last valid preview, even when a later incomplete/invalid input is present.
|
||||
#[cfg(test)]
|
||||
pub fn preview(&self) -> &T {
|
||||
&self.preview
|
||||
}
|
||||
|
||||
pub fn context(&self) -> &C {
|
||||
&self.context
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn is_valid(&self) -> bool {
|
||||
self.valid
|
||||
}
|
||||
|
||||
/// Accept a host-validated proposal. Errors invalidate commit without replacing the
|
||||
/// last valid preview. Units, mixed values, finite checks and bounds belong to the host.
|
||||
pub fn propose<E>(&mut self, proposal: Result<T, E>) -> Result<(), E> {
|
||||
match proposal {
|
||||
Ok(value) => {
|
||||
self.preview = value;
|
||||
self.valid = true;
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => {
|
||||
self.valid = false;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discard the local draft. No rollback commands or history entries are generated.
|
||||
pub fn cancel(self) {}
|
||||
}
|
||||
|
||||
impl<T: PartialEq, C: PartialEq> EditSession<T, C> {
|
||||
/// Check immediately before applying on the host's serialized command path.
|
||||
/// This comparison is not a lock and is not a distributed transaction.
|
||||
pub fn check(&self, live: &T, context: &C) -> Result<(), FinishError> {
|
||||
if *context != self.context {
|
||||
Err(FinishError::ContextChanged)
|
||||
} else if *live != self.original {
|
||||
Err(FinishError::ValueChanged)
|
||||
} else if !self.valid {
|
||||
Err(FinishError::InvalidProposal)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit at most one change; a no-op emits none, preserving the host's redo stack.
|
||||
/// The host still validates and applies the command and handles execution failure.
|
||||
pub fn finish(self, live: &T, context: &C) -> Result<Option<Change<T>>, FinishError> {
|
||||
self.check(live, context)?;
|
||||
if self.original == self.preview { Ok(None) } else { Ok(Some(Change { before: self.original, after: self.preview })) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,75 @@
|
||||
use super::{Change, EditSession, FinishError};
|
||||
|
||||
#[test]
|
||||
fn many_previews_produce_one_final_change() {
|
||||
let mut draft = EditSession::new(100, ("photo", "layer-1", 9));
|
||||
for value in 0..1000 {
|
||||
assert_eq!(draft.propose::<()>(Ok(value)), Ok(()));
|
||||
assert_eq!(*draft.original(), 100);
|
||||
}
|
||||
assert_eq!(draft.finish(&100, &("photo", "layer-1", 9)), Ok(Some(Change { before: 100, after: 999 })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_input_retains_preview_but_blocks_commit_until_corrected() {
|
||||
let mut draft = EditSession::new(10, 0);
|
||||
assert_eq!(draft.propose::<&str>(Ok(12)), Ok(()));
|
||||
assert_eq!(draft.propose(Err("incomplete number")), Err("incomplete number"));
|
||||
assert_eq!(*draft.preview(), 12);
|
||||
assert!(!draft.is_valid());
|
||||
assert_eq!(draft.check(&10, &0), Err(FinishError::InvalidProposal));
|
||||
assert_eq!(draft.propose::<&str>(Ok(14)), Ok(()));
|
||||
assert_eq!(draft.finish(&10, &0), Ok(Some(Change { before: 10, after: 14 })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_and_return_to_original_have_no_commit() {
|
||||
assert_eq!(EditSession::new(20, 0).finish(&20, &0), Ok(None));
|
||||
let mut draft = EditSession::new(20, 0);
|
||||
assert_eq!(draft.propose::<()>(Ok(50)), Ok(()));
|
||||
assert_eq!(draft.propose::<()>(Ok(20)), Ok(()));
|
||||
assert_eq!(draft.finish(&20, &0), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_document_target_or_revision_cannot_commit() {
|
||||
for context in [(2, 5, 8), (1, 6, 8), (1, 5, 9)] {
|
||||
let draft = EditSession::new(100, (1, 5, 8));
|
||||
assert_eq!(draft.finish(&100, &context), Err(FinishError::ContextChanged));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unversioned_value_change_is_also_rejected() {
|
||||
assert_eq!(EditSession::new(100, 1).finish(&50, &1), Err(FinishError::ValueChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_back_to_equal_value_is_stale_when_revision_has_advanced() {
|
||||
assert_eq!(EditSession::new(100, 1).finish(&100, &3), Err(FinishError::ContextChanged));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_final_input_does_not_silently_commit_last_valid_value() {
|
||||
let mut draft = EditSession::new(10, 0);
|
||||
assert_eq!(draft.propose::<&str>(Ok(12)), Ok(()));
|
||||
assert!(draft.propose(Err("not finite")).is_err());
|
||||
assert_eq!(draft.finish(&10, &0), Err(FinishError::InvalidProposal));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_values_preserve_inheritance_instead_of_flattening_it() {
|
||||
let mut draft = EditSession::new(None::<u16>, 0);
|
||||
assert_eq!(draft.propose::<()>(Ok(Some(20))), Ok(()));
|
||||
assert_eq!(draft.propose::<()>(Ok(None)), Ok(()));
|
||||
assert_eq!(draft.finish(&None, &0), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_does_not_touch_host_value() {
|
||||
let live = String::from("original");
|
||||
let mut draft = EditSession::new(live.clone(), 1);
|
||||
assert_eq!(draft.propose::<()>(Ok("preview".into())), Ok(()));
|
||||
draft.cancel();
|
||||
assert_eq!(live, "original");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ pub mod canvas;
|
||||
pub mod control;
|
||||
pub mod dialogs;
|
||||
pub mod dock;
|
||||
pub(crate) mod edit_session;
|
||||
pub mod file_dialog;
|
||||
pub mod file_open;
|
||||
pub mod frame_stages;
|
||||
@@ -152,6 +153,7 @@ pub use dialogs::fill_ui;
|
||||
pub use dialogs::filter_dialog;
|
||||
use dialogs::filter_preview_ui;
|
||||
pub use dialogs::gallery_ui;
|
||||
use dialogs::guide_edit;
|
||||
pub use dialogs::guide_layout_ui;
|
||||
pub use dialogs::history_options_ui;
|
||||
pub use dialogs::jpeg_options_ui;
|
||||
@@ -508,6 +510,7 @@ pub struct PhotocraftApp {
|
||||
pub(crate) proxy_uploaded: Option<(DocId, u64)>,
|
||||
/// Selection outline cache: (doc, revision, segments).
|
||||
/// Live filter preview (proxy document with the filter applied).
|
||||
pub(crate) guide_edit: Option<guide_edit::Draft>,
|
||||
pub(crate) filter_preview: Option<filter_dialog::FilterPreview>,
|
||||
pub(crate) smart_filter_targets: smart_filter_dialog::Targets,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Guide editing drafts and interaction checks
|
||||
|
||||
Edit Selected Guides previews orientation, position and colour on the canvas without editing
|
||||
the document. OK sends one `view.editGuide` command; Cancel and Escape discard the preview.
|
||||
An unchanged OK leaves Undo/Redo intact. Invalid text keeps the last valid preview and cannot
|
||||
commit. A document revision, target or ruler-unit change invalidates the draft; reopen the
|
||||
dialog to start again. Undoing a concurrent edit does not revive an old draft.
|
||||
If the final command is refused (for example by the automation policy), the dialog reopens
|
||||
with the typed fields, error and last valid preview. Retrying uses the original target and
|
||||
revision; switching documents or editing the target before retry still invalidates the draft.
|
||||
|
||||
The UI uses the `edit_session` module in ui-egui (`crates/ui-egui/src/edit_session.rs`) for the draft lifecycle and no-op/stale checks.
|
||||
PhotoCraft retains guide selection, parsing, rendering and history ownership. Validation runs
|
||||
the existing command on a temporary COW document only when parameters change. The live document
|
||||
is changed only by the ordinary command after successful confirmation. Rounded ruler text that
|
||||
was left untouched preserves the exact original position.
|
||||
|
||||
## Running the scenarios
|
||||
|
||||
The [interaction-check](https://github.com/PerchAnton/interaction-check) runner is development-only;
|
||||
Python is not a PhotoCraft runtime dependency. To run the scenarios locally, use the
|
||||
revision below. Automated execution needs a separate shared workflow and maintainer review.
|
||||
The adapter belongs to PhotoCraft and calls the shipped dialog handlers, control field setter,
|
||||
egui canvas/dialog frames, engine commands and history. Expectations are never passed to the
|
||||
adapter. These are semantic integration checks, not a substitute for screenshot or pointer tests.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/PerchAnton/interaction-check ../interaction-check
|
||||
git -C ../interaction-check checkout --detach f8d4282d61576ffe78d18553c642d215887e2d9f
|
||||
cargo build --locked -p photocraft-ui-egui --example guide_edit_scenarios
|
||||
python3 ../interaction-check/interaction_check.py --timeout 30 \
|
||||
--report guide-edit-report.json tests/interactions/guide-edit/*.json -- \
|
||||
target/debug/examples/guide_edit_scenarios
|
||||
```
|
||||
|
||||
Each scenario gets a fresh application with a 400 × 200 document at 100 ppi and vertical guides
|
||||
at 10 and 20 px. Reported state reads committed and preview guides, history depth, Redo, dialog
|
||||
validity and the real session command journal. The scenarios cover a single committed edit,
|
||||
Undo/Redo, cancellation, invalid input and recovery, no-ops, fractional positions, orientation
|
||||
and colour, document switching, deleted index reuse and an edit followed by Undo.
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "another-document",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "30 px"
|
||||
},
|
||||
"expect": {
|
||||
"preview": {
|
||||
"vertical": [
|
||||
30,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "other_document"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10
|
||||
],
|
||||
"preview": null,
|
||||
"invalid": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"rejected": true,
|
||||
"vertical": [
|
||||
10
|
||||
],
|
||||
"history": 1,
|
||||
"editCommands": 0,
|
||||
"editing": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "cancel-and-escape",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "30 px"
|
||||
},
|
||||
"expect": {
|
||||
"preview": {
|
||||
"vertical": [
|
||||
30,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "cancel"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": null,
|
||||
"editing": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "40 px"
|
||||
},
|
||||
"expect": {
|
||||
"preview": {
|
||||
"vertical": [
|
||||
40,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "escape"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": null,
|
||||
"editing": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "changed-ruler-units",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "command",
|
||||
"id": "prefs.set",
|
||||
"params": {
|
||||
"path": "unitsAndRulers.rulers",
|
||||
"value": "inches"
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"preview": null,
|
||||
"invalid": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"rejected": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "deleted-index-reused",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "command",
|
||||
"id": "view.deleteGuide",
|
||||
"params": {
|
||||
"orientation": "vertical",
|
||||
"index": 0
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
20
|
||||
],
|
||||
"preview": null,
|
||||
"invalid": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"rejected": true,
|
||||
"vertical": [
|
||||
20
|
||||
],
|
||||
"history": 3,
|
||||
"editCommands": 0,
|
||||
"editing": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "fractional-unchanged",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "command",
|
||||
"id": "view.moveGuide",
|
||||
"params": {
|
||||
"orientation": "vertical",
|
||||
"index": 0,
|
||||
"position": 10.123456789
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10.123456789,
|
||||
20
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "command",
|
||||
"id": "prefs.set",
|
||||
"params": {
|
||||
"path": "unitsAndRulers.rulers",
|
||||
"value": "cm"
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"rejected": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10.123456789,
|
||||
20
|
||||
],
|
||||
"history": 3,
|
||||
"editCommands": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "invalid-then-correct",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "30 px"
|
||||
},
|
||||
"expect": {
|
||||
"preview": {
|
||||
"vertical": [
|
||||
30,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "far"
|
||||
},
|
||||
"expect": {
|
||||
"invalid": true,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
30,
|
||||
20
|
||||
]
|
||||
},
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"rejected": true,
|
||||
"invalid": true,
|
||||
"editing": true,
|
||||
"history": 2,
|
||||
"editCommands": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "40 px"
|
||||
},
|
||||
"expect": {
|
||||
"invalid": false,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
40,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"rejected": false,
|
||||
"vertical": [
|
||||
40,
|
||||
20
|
||||
],
|
||||
"history": 3,
|
||||
"editCommands": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "orientation-and-color",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "orientation",
|
||||
"value": "horizontal"
|
||||
},
|
||||
"expect": {
|
||||
"preview": {
|
||||
"vertical": [
|
||||
20
|
||||
],
|
||||
"horizontal": [
|
||||
10
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "50%"
|
||||
},
|
||||
"expect": {
|
||||
"preview": {
|
||||
"horizontal": [
|
||||
100
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "color",
|
||||
"value": "magenta"
|
||||
},
|
||||
"expect": {
|
||||
"colors": [],
|
||||
"preview": {
|
||||
"colors": [
|
||||
{
|
||||
"vertical": false,
|
||||
"position": 100,
|
||||
"rgb": [
|
||||
255,
|
||||
0,
|
||||
255
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
20
|
||||
],
|
||||
"horizontal": [
|
||||
100
|
||||
],
|
||||
"history": 3,
|
||||
"editCommands": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "preview-commit-undo-redo",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "set",
|
||||
"field": "position",
|
||||
"value": "25%"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"preview": {
|
||||
"vertical": [
|
||||
100,
|
||||
20
|
||||
]
|
||||
},
|
||||
"history": 2,
|
||||
"editCommands": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
100,
|
||||
20
|
||||
],
|
||||
"history": 3,
|
||||
"editCommands": 1,
|
||||
"editing": false,
|
||||
"preview": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "undo"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"redo": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "redo"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
100,
|
||||
20
|
||||
],
|
||||
"redo": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "unchanged-preserves-redo",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "command",
|
||||
"id": "view.moveGuide",
|
||||
"params": {
|
||||
"orientation": "vertical",
|
||||
"index": 0,
|
||||
"position": 15
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
15,
|
||||
20
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "undo"
|
||||
},
|
||||
"expect": {
|
||||
"redo": true,
|
||||
"history": 2
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"redo": true,
|
||||
"editCommands": 0,
|
||||
"editing": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"name": "undo-does-not-revive-draft",
|
||||
"steps": [
|
||||
{
|
||||
"event": {
|
||||
"op": "begin"
|
||||
},
|
||||
"expect": {
|
||||
"editing": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"history": 2,
|
||||
"editCommands": 0,
|
||||
"preview": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "command",
|
||||
"id": "view.moveGuide",
|
||||
"params": {
|
||||
"orientation": "vertical",
|
||||
"index": 0,
|
||||
"position": 15
|
||||
}
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
15,
|
||||
20
|
||||
],
|
||||
"preview": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "undo"
|
||||
},
|
||||
"expect": {
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"redo": true,
|
||||
"preview": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"event": {
|
||||
"op": "confirm"
|
||||
},
|
||||
"expect": {
|
||||
"rejected": true,
|
||||
"vertical": [
|
||||
10,
|
||||
20
|
||||
],
|
||||
"redo": true,
|
||||
"history": 2,
|
||||
"editCommands": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user