mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge pull request #2626 from storytold/land/1439
#1436: settings dialogs preview live and leave the window undimmed (Gradient Editor, Solid, Camera, Light, Composition) (#1439)
This commit is contained in:
@@ -387,6 +387,11 @@ impl EffectcraftApp {
|
||||
let full = ui.max_rect();
|
||||
if self.dialog.is_some() {
|
||||
ui.disable();
|
||||
// A dialog that edits what is behind it live (Preview) leaves the window at full
|
||||
// brightness: clicks still don't reach it, but the canvas is not greyed out.
|
||||
if self.dialog == Some(Dialog::GradientEditor) || panels::live_preview::active(self) {
|
||||
ui.set_opacity(1.0);
|
||||
}
|
||||
}
|
||||
ui.painter().rect_filled(full, 0.0, t.app_bg);
|
||||
let mut top = full.min.y;
|
||||
|
||||
@@ -56,6 +56,18 @@ impl EffectcraftApp {
|
||||
&mut self.dialog_state.gradient
|
||||
}
|
||||
|
||||
/// The open settings dialog's draft and Preview state (`panels::dialogs`): Solid, Camera,
|
||||
/// Light and Composition Settings keep what OK would apply here.
|
||||
pub fn dialog_state(&self) -> &panels::dialogs::DialogState {
|
||||
&self.dialog_state
|
||||
}
|
||||
|
||||
/// The open settings dialog's draft, to edit (tests and automation; the dialog's own fields
|
||||
/// do the same).
|
||||
pub fn dialog_state_mut(&mut self) -> &mut panels::dialogs::DialogState {
|
||||
&mut self.dialog_state
|
||||
}
|
||||
|
||||
/// Show panel `p` (opening it in its usual place if it is closed), bring it to the front and
|
||||
/// give it the focus: Window ▸ `<panel>`.
|
||||
pub fn show_panel(&mut self, p: PanelKind) {
|
||||
|
||||
@@ -210,6 +210,9 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
|
||||
if app.dialog == Some(crate::Dialog::LayerStyles) {
|
||||
crate::panels::layer_styles_dialog::finish(app, false);
|
||||
} else if app.dialog == Some(crate::Dialog::GradientEditor) {
|
||||
// Escape is Cancel: the preview rolls back.
|
||||
crate::panels::gradient_editor::finish(app, false, ctx);
|
||||
} else if app.dialog == Some(crate::Dialog::Info) {
|
||||
crate::panels::forms::close_info(app);
|
||||
} else {
|
||||
@@ -222,10 +225,37 @@ fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
if app.dialog == Some(crate::Dialog::UnsavedChanges) {
|
||||
app.dialog_state.unsaved.command = None;
|
||||
}
|
||||
// Escape is Cancel: a Preview that edited the project rolls back.
|
||||
crate::panels::live_preview::cancel(app);
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape with a dialog open: it is Cancel. Escape while recording a shortcut cancels the
|
||||
/// recording, not the editor; so does Escape in its Delete Preset prompt.
|
||||
fn cancel_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) {
|
||||
return;
|
||||
}
|
||||
if app.dialog == Some(crate::Dialog::Info) {
|
||||
crate::panels::forms::close_info(app);
|
||||
return;
|
||||
}
|
||||
if app.dialog == Some(crate::Dialog::GradientEditor) {
|
||||
crate::panels::gradient_editor::finish(app, false, ctx);
|
||||
return;
|
||||
}
|
||||
// Escape in Settings is Cancel: restore the settings from when it opened.
|
||||
if app.dialog == Some(crate::Dialog::Settings)
|
||||
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
|
||||
{
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
crate::panels::live_preview::cancel(app);
|
||||
app.dialog = None;
|
||||
}
|
||||
|
||||
type KeyEvent = (egui::Key, egui::Modifiers, bool, Option<egui::Key>);
|
||||
|
||||
/// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V.
|
||||
@@ -341,23 +371,7 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
|
||||
}
|
||||
// Escape closes dialogs.
|
||||
if key == egui::Key::Escape && app.dialog.is_some() {
|
||||
// Escape while recording a shortcut cancels the recording, not the editor; so does
|
||||
// Escape in its Delete Preset prompt.
|
||||
if crate::panels::shortcut_editor::recording(app) || crate::panels::shortcut_editor::confirming(app) {
|
||||
continue;
|
||||
}
|
||||
if app.dialog == Some(crate::Dialog::Info) {
|
||||
crate::panels::forms::close_info(app);
|
||||
continue;
|
||||
}
|
||||
// Escape in Settings is Cancel: restore the settings from when it opened.
|
||||
if app.dialog == Some(crate::Dialog::Settings)
|
||||
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
|
||||
{
|
||||
app.session.prefs = p;
|
||||
app.session.prefs_changed();
|
||||
}
|
||||
app.dialog = None;
|
||||
cancel_dialog(app, ctx);
|
||||
continue;
|
||||
}
|
||||
if app.dialog.is_some() {
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod key_dialogs;
|
||||
pub mod layer_panel;
|
||||
pub mod layer_styles_dialog;
|
||||
pub mod learn;
|
||||
pub mod live_preview;
|
||||
pub mod markers_ui;
|
||||
pub mod media_panels;
|
||||
pub mod misc;
|
||||
@@ -35,6 +36,7 @@ pub mod paint_panels;
|
||||
pub mod panel_kit;
|
||||
pub mod path_vr_panels;
|
||||
pub mod precomp;
|
||||
pub(crate) mod preview_merge;
|
||||
pub mod primitive_dialog;
|
||||
pub mod project;
|
||||
pub mod properties;
|
||||
|
||||
@@ -487,6 +487,7 @@ pub fn show(
|
||||
auto: &mut Registry,
|
||||
custom: &[CompPreset],
|
||||
cmds: &mut Vec<(&'static str, serde_json::Value)>,
|
||||
preview: Option<&mut bool>,
|
||||
) -> Option<serde_json::Value> {
|
||||
let mut out = None;
|
||||
ui.horizontal(|ui| {
|
||||
@@ -528,6 +529,9 @@ pub fn show(
|
||||
if r.clicked() {
|
||||
out = Some(serde_json::Value::Null);
|
||||
}
|
||||
if let Some(on) = preview {
|
||||
super::live_preview::checkbox(ui, auto, on, "dialog.comp.preview");
|
||||
}
|
||||
});
|
||||
});
|
||||
out
|
||||
|
||||
@@ -66,6 +66,8 @@ pub struct DialogState {
|
||||
pub delete_items: super::delete_items::Pending,
|
||||
/// The Gradient Editor's draft and the property it edits.
|
||||
pub gradient: super::gradient_editor::Draft,
|
||||
/// Preview of the settings dialog being edited (Solid, Camera, Light, Composition Settings).
|
||||
pub live: super::live_preview::LivePreview,
|
||||
/// The user template the Delete Template prompt asks about: its id and name.
|
||||
pub delete_template: Option<(String, String)>,
|
||||
}
|
||||
@@ -76,6 +78,7 @@ pub fn open_new_comp(app: &mut EffectcraftApp) {
|
||||
app.dialog_state.comp =
|
||||
CompDraft { name: format!("Comp {n}"), tab: super::comp_settings::Tab::Basic, start_tc: None, dur_tc: None, preset_name: None, ..last };
|
||||
app.dialog_state.editing_existing = false;
|
||||
app.dialog_state.live = Default::default();
|
||||
app.dialog = Some(Dialog::NewComp);
|
||||
}
|
||||
|
||||
@@ -109,6 +112,7 @@ pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<(
|
||||
app.dialog_state.editing_existing = true;
|
||||
app.dialog_state.editing_comp = Some(cid);
|
||||
app.dialog = Some(Dialog::CompSettings);
|
||||
super::live_preview::begin(app, Dialog::CompSettings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -123,6 +127,7 @@ pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> {
|
||||
if d.solid_color == [0.0; 3] {
|
||||
d.solid_color = [0.2, 0.45, 0.85];
|
||||
}
|
||||
d.live = Default::default();
|
||||
app.dialog = Some(Dialog::SolidSettings);
|
||||
Ok(())
|
||||
}
|
||||
@@ -138,6 +143,7 @@ fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Re
|
||||
(d.solid_name, d.solid_color, d.solid_size, d.solid_pixel_aspect) = (name, so.color, [so.width, so.height], so.pixel_aspect);
|
||||
(d.solid_layer, d.solid_shared, d.solid_affect_all) = (Some(layer), users > 1, false);
|
||||
app.dialog = Some(Dialog::SolidSettings);
|
||||
super::live_preview::begin(app, Dialog::SolidSettings);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -190,6 +196,31 @@ pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2,
|
||||
modal_with_scroll(ctx, title, size, t, true, body);
|
||||
}
|
||||
|
||||
/// Where a modal dialog opens and whether it dims the window behind it. The default is the middle
|
||||
/// of the window over a dimmed backdrop.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Placement {
|
||||
/// The opening position as a fraction of the room the dialog can move in, from the middle
|
||||
/// `(0, 0)` to the bottom right `(1, 1)`.
|
||||
pub start: egui::Vec2,
|
||||
/// Dim the window behind the dialog.
|
||||
pub dim: bool,
|
||||
}
|
||||
|
||||
impl Default for Placement {
|
||||
fn default() -> Self {
|
||||
Placement { start: vec2(0.0, 0.0), dim: true }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`modal`] at `place`: a dialog that edits what is behind it keeps the canvas in view.
|
||||
pub(crate) fn modal_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui)) {
|
||||
let max_height = (modal_space(ctx).y - 84.0).max(40.0);
|
||||
modal_fit_placed(ctx, title, size, t, place, |ui, _| {
|
||||
egui::ScrollArea::both().max_height(max_height).auto_shrink([false, true]).show(ui, body);
|
||||
});
|
||||
}
|
||||
|
||||
fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, vertical_only: bool, body: impl FnOnce(&mut egui::Ui)) {
|
||||
let max_height = (modal_space(ctx).y - 84.0).max(40.0);
|
||||
modal_fit(ctx, title, size, t, |ui, _| {
|
||||
@@ -202,6 +233,11 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok
|
||||
/// only its content: `body` gets the height left under the title, with `size` kept within the
|
||||
/// window.
|
||||
pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) {
|
||||
modal_fit_placed(ctx, title, size, t, Placement::default(), body);
|
||||
}
|
||||
|
||||
/// [`modal_fit`] at `place`.
|
||||
pub(crate) fn modal_fit_placed(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, place: Placement, body: impl FnOnce(&mut egui::Ui, f32)) {
|
||||
let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT);
|
||||
ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass));
|
||||
let size = size.min(modal_space(ctx));
|
||||
@@ -210,10 +246,10 @@ pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &
|
||||
// window, so dragging back never has to undo a part the dialog could not follow.
|
||||
let offset_id = id.with("offset");
|
||||
let room = ((ctx.content_rect().size() - size) * 0.5).max(vec2(0.0, 0.0));
|
||||
let offset = ctx.data(|d| d.get_temp::<egui::Vec2>(offset_id)).unwrap_or_default().clamp(-room, room);
|
||||
let offset = ctx.data(|d| d.get_temp::<egui::Vec2>(offset_id)).unwrap_or(room * place.start).clamp(-room, room);
|
||||
egui::Modal::new(id)
|
||||
.area(egui::Modal::default_area(id).anchor(Align2::CENTER_CENTER, offset))
|
||||
.backdrop_color(Color32::from_black_alpha(120))
|
||||
.backdrop_color(Color32::from_black_alpha(if place.dim { 120 } else { 0 }))
|
||||
.frame(egui::Frame::window(&ctx.style_of(ctx.theme())).fill(t.panel_bg).inner_margin(egui::Margin::same(18)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(size.x - 36.0);
|
||||
@@ -405,9 +441,13 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
|
||||
let mut cmds = Vec::new();
|
||||
// The title keys the modal id and its drag offset; `modal_fit` translates the heading itself,
|
||||
// so passing the source keeps the dialog in place when the interface language changes.
|
||||
modal(ctx, "Composition Settings", vec2(600.0, 470.0), t, |ui| {
|
||||
result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds);
|
||||
let live = existing && super::live_preview::active(app);
|
||||
let place = super::live_preview::place(app);
|
||||
let mut preview = app.dialog_state.live.on;
|
||||
modal_placed(ctx, "Composition Settings", vec2(600.0, 470.0), t, place, |ui| {
|
||||
result = super::comp_settings::show(ui, &mut d, t, &mut app.auto, &custom, &mut cmds, live.then_some(&mut preview));
|
||||
});
|
||||
app.dialog_state.live.on = preview;
|
||||
if d.preset_name.is_some() {
|
||||
// Save Preset asks for the name in its own window over the dialog, as After Effects does.
|
||||
result = None;
|
||||
@@ -425,11 +465,25 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
|
||||
}
|
||||
app.dialog_state.comp = d;
|
||||
match result {
|
||||
Some(serde_json::Value::Null) => app.dialog = None,
|
||||
Some(serde_json::Value::Null) => {
|
||||
super::live_preview::cancel(app);
|
||||
app.dialog = None;
|
||||
}
|
||||
Some(mut params) => {
|
||||
if existing && let (Some(cid), Some(m)) = (app.dialog_state.editing_comp, params.as_object_mut()) {
|
||||
m.insert("comp".into(), json!(cid.0));
|
||||
}
|
||||
// A preview already made the edit (one undo step): OK keeps it.
|
||||
if existing && super::live_preview::keep(app, "comp.settings", params.clone()) {
|
||||
// The Resolution is not part of the command, so the preview never applied it.
|
||||
if let Some(cid) = app.dialog_state.editing_comp
|
||||
&& let Err(e) = set_resolution(app, cid.0, app.dialog_state.comp.resolution)
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.dialog = None;
|
||||
return;
|
||||
}
|
||||
let r = if existing { app.session.execute("comp.settings", params) } else { app.session.execute("comp.new", params) };
|
||||
let cid = if existing { app.dialog_state.editing_comp.map(|c| c.0) } else { r.as_ref().ok().and_then(|v| v["comp"].as_u64()) };
|
||||
let r = match cid {
|
||||
@@ -444,7 +498,15 @@ fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, exis
|
||||
}
|
||||
app.dialog = None;
|
||||
}
|
||||
None => {}
|
||||
None => {
|
||||
if existing && let Some(cid) = app.dialog_state.editing_comp {
|
||||
let mut params = super::comp_settings::params(&app.dialog_state.comp);
|
||||
if let Some(m) = params.as_object_mut() {
|
||||
m.insert("comp".into(), json!(cid.0));
|
||||
}
|
||||
super::live_preview::sync(app, "comp.settings", params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -501,8 +563,11 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let comp_size = app.session.active_comp().map(|c| (c.width, c.height)).unwrap_or((1920, 1080));
|
||||
let lock_id = egui::Id::unique("solid-lock-aspect");
|
||||
let mut lock = ctx.data(|m| m.get_temp::<bool>(lock_id)).unwrap_or(true);
|
||||
let live = super::live_preview::active(app);
|
||||
let place = super::live_preview::place(app);
|
||||
let mut preview = app.dialog_state.live.on;
|
||||
let auto = &mut app.auto;
|
||||
modal(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, |ui| {
|
||||
modal_placed(ctx, "Solid Settings", vec2(480.0, if editing.is_some() { 430.0 } else { 390.0 }), t, place, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tr("Name:"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut name).desired_width(320.0));
|
||||
@@ -588,16 +653,28 @@ fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let r = ui.button(tr("Cancel"));
|
||||
auto.add("dialog.solid.cancel", r.rect, "Cancel");
|
||||
close = r.clicked();
|
||||
if live {
|
||||
super::live_preview::checkbox(ui, auto, &mut preview, "dialog.solid.preview");
|
||||
}
|
||||
});
|
||||
});
|
||||
ctx.data_mut(|m| m.insert_temp(lock_id, lock));
|
||||
let ds = &mut app.dialog_state;
|
||||
ds.live.on = preview;
|
||||
(ds.solid_name, ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all) = (name, color, size, par, affect_all);
|
||||
let (command, params) = (solid_command(ds), solid_params(ds));
|
||||
if ok || ui_enter(ctx) {
|
||||
if let Err(e) = app.session.execute(solid_command(ds), solid_params(ds)) {
|
||||
// A preview already made the edit (one undo step): OK keeps it.
|
||||
if !super::live_preview::keep(app, command, params.clone())
|
||||
&& let Err(e) = app.session.execute(command, params)
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
close = true;
|
||||
} else if close {
|
||||
super::live_preview::cancel(app);
|
||||
} else {
|
||||
super::live_preview::sync(app, command, params);
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
|
||||
@@ -99,8 +99,14 @@ pub fn open_camera(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), S
|
||||
}
|
||||
}
|
||||
};
|
||||
let editing = d.layer.is_some();
|
||||
app.dialog_state.camera = d;
|
||||
app.dialog = Some(Dialog::CameraSettings);
|
||||
if editing {
|
||||
super::live_preview::begin(app, Dialog::CameraSettings);
|
||||
} else {
|
||||
app.dialog_state.live = Default::default();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -149,13 +155,19 @@ pub fn open_light(app: &mut EffectcraftApp, layer: Option<u64>) -> Result<(), St
|
||||
}
|
||||
}
|
||||
};
|
||||
let editing = d.layer.is_some();
|
||||
app.dialog_state.light = d;
|
||||
app.dialog = Some(Dialog::LightSettings);
|
||||
if editing {
|
||||
super::live_preview::begin(app, Dialog::LightSettings);
|
||||
} else {
|
||||
app.dialog_state.live = Default::default();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (egui::Rect, egui::Rect) {
|
||||
let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING);
|
||||
fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool, preview: Option<&mut bool>) -> (egui::Rect, egui::Rect, egui::Rect) {
|
||||
let mut r = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let b = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
|
||||
r.0 = b.rect;
|
||||
@@ -167,6 +179,9 @@ fn buttons(ui: &mut egui::Ui, t: &Tokens, ok: &mut bool, close: &mut bool) -> (e
|
||||
if c.clicked() {
|
||||
*close = true;
|
||||
}
|
||||
if let Some(on) = preview {
|
||||
r.2 = ui.checkbox(on, tr("Preview")).rect;
|
||||
}
|
||||
});
|
||||
r
|
||||
}
|
||||
@@ -175,8 +190,11 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let w = comp_w(app);
|
||||
let mut d = app.dialog_state.camera.clone();
|
||||
let (mut ok, mut close) = (false, false);
|
||||
let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING);
|
||||
super::dialogs::modal(ctx, "Camera Settings", vec2(520.0, 420.0), t, |ui| {
|
||||
let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING);
|
||||
let live = super::live_preview::active(app);
|
||||
let place = super::live_preview::place(app);
|
||||
let mut preview = app.dialog_state.live.on;
|
||||
super::dialogs::modal_placed(ctx, "Camera Settings", vec2(520.0, 420.0), t, place, |ui| {
|
||||
egui::Grid::new("cam-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
|
||||
ui.label(tr("Type"));
|
||||
craft_ui::ComboBox::from_id_salt("cam-type").selected_text(if d.two_node { "Two-Node Camera" } else { "One-Node Camera" }).show_ui(ui, |ui| {
|
||||
@@ -229,46 +247,66 @@ pub fn camera(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(14.0);
|
||||
rects = buttons(ui, t, &mut ok, &mut close);
|
||||
rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview));
|
||||
});
|
||||
app.dialog_state.live.on = preview;
|
||||
if d.lock_to_zoom {
|
||||
d.focus = d.zoom;
|
||||
}
|
||||
app.auto.add("dialog.camera.ok", rects.0, "OK");
|
||||
app.auto.add("dialog.camera.cancel", rects.1, "Cancel");
|
||||
if live {
|
||||
app.auto.add("dialog.camera.preview", rects.2, "Preview");
|
||||
}
|
||||
app.dialog_state.camera = d.clone();
|
||||
let (id, p) = camera_command(&d);
|
||||
if ok || super::dialogs::ui_enter(ctx) {
|
||||
let mut p = json!({
|
||||
"name": d.name,
|
||||
"type": if d.two_node { "twoNode" } else { "oneNode" },
|
||||
"zoom": d.zoom,
|
||||
"dof": d.dof,
|
||||
"focusDistance": d.focus,
|
||||
"aperture": d.aperture,
|
||||
"blurLevel": d.blur,
|
||||
});
|
||||
let id = match d.layer {
|
||||
Some(l) => {
|
||||
p["layer"] = json!(l);
|
||||
"layer.cameraSettings"
|
||||
}
|
||||
None => "layer.newCamera",
|
||||
};
|
||||
if let Err(e) = app.session.execute(id, p) {
|
||||
// A preview already made the edit (one undo step): OK keeps it.
|
||||
if !super::live_preview::keep(app, id, p.clone())
|
||||
&& let Err(e) = app.session.execute(id, p)
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
close = true;
|
||||
} else if close {
|
||||
super::live_preview::cancel(app);
|
||||
} else {
|
||||
super::live_preview::sync(app, id, p);
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The command Camera Settings' OK runs, and its parameters.
|
||||
fn camera_command(d: &CameraDraft) -> (&'static str, Value) {
|
||||
let mut p = json!({
|
||||
"name": d.name,
|
||||
"type": if d.two_node { "twoNode" } else { "oneNode" },
|
||||
"zoom": d.zoom,
|
||||
"dof": d.dof,
|
||||
"focusDistance": d.focus,
|
||||
"aperture": d.aperture,
|
||||
"blurLevel": d.blur,
|
||||
});
|
||||
let id = match d.layer {
|
||||
Some(l) => {
|
||||
p["layer"] = json!(l);
|
||||
"layer.cameraSettings"
|
||||
}
|
||||
None => "layer.newCamera",
|
||||
};
|
||||
(id, p)
|
||||
}
|
||||
|
||||
pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
let mut d = app.dialog_state.light.clone();
|
||||
let (mut ok, mut close) = (false, false);
|
||||
let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING);
|
||||
super::dialogs::modal(ctx, "Light Settings", vec2(480.0, 470.0), t, |ui| {
|
||||
let mut rects = (egui::Rect::NOTHING, egui::Rect::NOTHING, egui::Rect::NOTHING);
|
||||
let live = super::live_preview::active(app);
|
||||
let place = super::live_preview::place(app);
|
||||
let mut preview = app.dialog_state.live.on;
|
||||
super::dialogs::modal_placed(ctx, "Light Settings", vec2(480.0, 470.0), t, place, |ui| {
|
||||
egui::Grid::new("light-grid").num_columns(2).spacing([14.0, 9.0]).show(ui, |ui| {
|
||||
ui.label(tr("Name"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.name).desired_width(220.0));
|
||||
@@ -332,50 +370,67 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
rects = buttons(ui, t, &mut ok, &mut close);
|
||||
rects = buttons(ui, t, &mut ok, &mut close, live.then_some(&mut preview));
|
||||
});
|
||||
app.dialog_state.live.on = preview;
|
||||
app.auto.add("dialog.light.ok", rects.0, "OK");
|
||||
app.auto.add("dialog.light.cancel", rects.1, "Cancel");
|
||||
if live {
|
||||
app.auto.add("dialog.light.preview", rects.2, "Preview");
|
||||
}
|
||||
app.dialog_state.light = d.clone();
|
||||
let (id, p) = light_command(&d);
|
||||
if ok || super::dialogs::ui_enter(ctx) {
|
||||
let kind = d.kind;
|
||||
let mut p = json!({
|
||||
"name": d.name,
|
||||
"kind": kind.label(),
|
||||
"color": [d.color[0], d.color[1], d.color[2]],
|
||||
"intensity": d.intensity,
|
||||
});
|
||||
if kind == LightKind::Spot {
|
||||
p["coneAngle"] = json!(d.cone_angle);
|
||||
p["coneFeather"] = json!(d.cone_feather);
|
||||
}
|
||||
if falls_off(kind) {
|
||||
p["falloff"] = json!(d.falloff);
|
||||
p["radius"] = json!(d.radius);
|
||||
p["falloffDistance"] = json!(d.falloff_distance);
|
||||
}
|
||||
if casts_shadows(kind) {
|
||||
p["castsShadows"] = json!(d.casts_shadows);
|
||||
p["shadowDarkness"] = json!(d.shadow_darkness);
|
||||
p["shadowDiffusion"] = json!(d.shadow_diffusion);
|
||||
}
|
||||
let id = match d.layer {
|
||||
Some(l) => {
|
||||
p["layer"] = json!(l);
|
||||
"layer.lightSettings"
|
||||
}
|
||||
None => "layer.newLight",
|
||||
};
|
||||
if let Err(e) = app.session.execute(id, p) {
|
||||
// A preview already made the edit (one undo step): OK keeps it.
|
||||
if !super::live_preview::keep(app, id, p.clone())
|
||||
&& let Err(e) = app.session.execute(id, p)
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
close = true;
|
||||
} else if close {
|
||||
super::live_preview::cancel(app);
|
||||
} else {
|
||||
super::live_preview::sync(app, id, p);
|
||||
}
|
||||
if close {
|
||||
app.dialog = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The command Light Settings' OK runs, and its parameters.
|
||||
fn light_command(d: &LightDraft) -> (&'static str, Value) {
|
||||
let kind = d.kind;
|
||||
let mut p = json!({
|
||||
"name": d.name,
|
||||
"kind": kind.label(),
|
||||
"color": [d.color[0], d.color[1], d.color[2]],
|
||||
"intensity": d.intensity,
|
||||
});
|
||||
if kind == LightKind::Spot {
|
||||
p["coneAngle"] = json!(d.cone_angle);
|
||||
p["coneFeather"] = json!(d.cone_feather);
|
||||
}
|
||||
if falls_off(kind) {
|
||||
p["falloff"] = json!(d.falloff);
|
||||
p["radius"] = json!(d.radius);
|
||||
p["falloffDistance"] = json!(d.falloff_distance);
|
||||
}
|
||||
if casts_shadows(kind) {
|
||||
p["castsShadows"] = json!(d.casts_shadows);
|
||||
p["shadowDarkness"] = json!(d.shadow_darkness);
|
||||
p["shadowDiffusion"] = json!(d.shadow_diffusion);
|
||||
}
|
||||
let id = match d.layer {
|
||||
Some(l) => {
|
||||
p["layer"] = json!(l);
|
||||
"layer.lightSettings"
|
||||
}
|
||||
None => "layer.newLight",
|
||||
};
|
||||
(id, p)
|
||||
}
|
||||
|
||||
/// Menu/shortcut routing: commands that open a 3D settings dialog when invoked without params.
|
||||
/// Returns true when a dialog was opened.
|
||||
pub fn route(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
//! Opacity stops sit above the preview bar and colour stops below it. Click above or below the
|
||||
//! bar to add a stop (it takes that track's value there), drag a stop to move it, edit the
|
||||
//! selected stop's location, opacity or colour, and Delete removes it (never the last stop of a
|
||||
//! track). The editor works on a draft: OK sets the property once with `prop.set` at the time it
|
||||
//! opened (one undo step, nothing at all when nothing changed); Cancel and Escape leave it as it
|
||||
//! was.
|
||||
//! track). The editor works on a draft. With **Preview** on (the default, as in After Effects) each
|
||||
//! edit is applied to the property at once, with `prop.set` at the time the editor opened and one
|
||||
//! merged undo step, so the canvas shows the gradient as it is edited; OK keeps it, Cancel and
|
||||
//! Escape roll it back to the checkpoint taken when the editor opened. With Preview off, OK sets
|
||||
//! the property once (one undo step, nothing at all when nothing changed). The dialog opens at
|
||||
//! the lower right of the window and does not dim it, so the canvas stays in view.
|
||||
//!
|
||||
//! Automation ids: `dialog.gradient.bar`, `dialog.gradient.addOpacity`, `dialog.gradient.addColor`,
|
||||
//! `dialog.gradient.opacityStop.<i>`, `dialog.gradient.colorStop.<i>` (in order along the bar),
|
||||
//! `dialog.gradient.location`, `dialog.gradient.opacity`, `dialog.gradient.color`,
|
||||
//! `dialog.gradient.delete`, `dialog.gradient.ok`, `dialog.gradient.cancel`.
|
||||
//! `dialog.gradient.delete`, `dialog.gradient.preview`, `dialog.gradient.ok`,
|
||||
//! `dialog.gradient.cancel`.
|
||||
|
||||
use effectcraft_engine::keyframe::{Gradient, Value};
|
||||
use effectcraft_engine::project::{ItemId, LayerId};
|
||||
@@ -18,6 +22,8 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint};
|
||||
use crate::panels::preview_merge::{self, Applied};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
@@ -58,6 +64,14 @@ pub struct Draft {
|
||||
pub baseline: Gradient,
|
||||
/// Stop counts (colour, opacity) of the keyframes just before and after the edit time.
|
||||
pub neighbors: Vec<(usize, usize)>,
|
||||
/// Preview: edits are applied to the property as they are made.
|
||||
pub preview: bool,
|
||||
/// The gradient the property was last given by the preview, if it has been.
|
||||
pub applied: Option<Gradient>,
|
||||
/// The project as the preview left it, to tell edits made since from the preview's.
|
||||
applied_at: Option<Applied>,
|
||||
/// The project and undo history as the editor opened, for Cancel.
|
||||
checkpoint: Option<Checkpoint>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
@@ -220,7 +234,10 @@ pub fn open(app: &mut EffectcraftApp, p: &Json) -> Result<Json, String> {
|
||||
let before = prop.keys.iter().rev().find(|k| k.time < lt).and_then(|k| counts(&k.value));
|
||||
let after = prop.keys.iter().find(|k| k.time > lt).and_then(|k| counts(&k.value));
|
||||
let target = Target { comp: cid.0, layer: lid, prop: uid, time: ct.seconds() };
|
||||
app.dialog_state.gradient = Draft::new(&g, Some(target), before.into_iter().chain(after).collect());
|
||||
let mut draft = Draft::new(&g, Some(target), before.into_iter().chain(after).collect());
|
||||
draft.preview = true;
|
||||
draft.checkpoint = Some(checkpoint(app));
|
||||
app.dialog_state.gradient = draft;
|
||||
app.dialog = Some(Dialog::GradientEditor);
|
||||
Ok(json!({"dialog": "gradientEditor"}))
|
||||
}
|
||||
@@ -231,6 +248,11 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
app.dialog = None;
|
||||
let d = std::mem::take(&mut app.dialog_state.gradient);
|
||||
let g = d.gradient();
|
||||
// The preview already set the property to this draft, in one merged undo step: OK keeps it.
|
||||
if d.applied.as_ref() == Some(&g) {
|
||||
app.session.history.merge_key = None;
|
||||
return;
|
||||
}
|
||||
let Some(tg) = d.target else { return };
|
||||
if g == d.baseline {
|
||||
return;
|
||||
@@ -241,6 +263,55 @@ fn commit(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the editor without keeping the preview: the project and undo history go back to how they
|
||||
/// were when it opened (nothing happens when the preview never changed the property).
|
||||
pub fn finish(app: &mut EffectcraftApp, ok: bool, ctx: &egui::Context) {
|
||||
if ok {
|
||||
// An edit made in the same frame as OK is not in the last preview yet.
|
||||
sync_preview(app, ctx);
|
||||
commit(app, ctx);
|
||||
return;
|
||||
}
|
||||
let d = std::mem::take(&mut app.dialog_state.gradient);
|
||||
if d.applied.is_some()
|
||||
&& let Some(cp) = d.checkpoint
|
||||
{
|
||||
preview_merge::rollback(app, cp, d.applied_at.as_ref());
|
||||
}
|
||||
app.session.history.merge_key = None;
|
||||
app.dialog = None;
|
||||
}
|
||||
|
||||
/// Keep the property in step with the draft while Preview is on: apply a changed draft with one
|
||||
/// merged undo step, and roll back to the checkpoint when Preview goes off or the draft is back to
|
||||
/// how it opened.
|
||||
fn sync_preview(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
let d = &app.dialog_state.gradient;
|
||||
let Some(tg) = d.target.clone() else { return };
|
||||
let g = d.gradient();
|
||||
let want = d.preview && g != d.baseline;
|
||||
if want && d.applied.as_ref() != Some(&g) {
|
||||
let params = json!({
|
||||
"comp": tg.comp, "layer": tg.layer, "prop": tg.prop, "time": tg.time, "timeBase": "comp",
|
||||
"value": Value::Gradient(g.clone()).to_json(), "merge": format!("gradient-editor-{}", tg.prop),
|
||||
});
|
||||
match crate::menus::invoke(app, ctx, "prop.set", params) {
|
||||
Ok(_) => {
|
||||
app.dialog_state.gradient.applied = Some(g);
|
||||
app.dialog_state.gradient.applied_at = Some(preview_merge::applied(app));
|
||||
}
|
||||
Err(e) => app.ui.status = e,
|
||||
}
|
||||
} else if !want
|
||||
&& d.applied.is_some()
|
||||
&& let Some(cp) = d.checkpoint.clone()
|
||||
{
|
||||
let at = app.dialog_state.gradient.applied_at.take();
|
||||
preview_merge::rollback(app, cp, at.as_ref());
|
||||
app.dialog_state.gradient.applied = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn to_color32(c: [f32; 4]) -> Color32 {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
Color32::from_rgba_unmultiplied(b(c[0]), b(c[1]), b(c[2]), b(c[3]))
|
||||
@@ -258,7 +329,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
return;
|
||||
}
|
||||
let mut answer: Option<bool> = None;
|
||||
super::dialogs::modal(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, |ui| {
|
||||
// At the lower right, undimmed: the canvas it edits stays in view (After Effects).
|
||||
super::dialogs::modal_placed(ctx, tr("Gradient Editor"), vec2(520.0, 270.0), t, super::live_preview::PLACE, |ui| {
|
||||
let w = ui.available_width().max(200.0);
|
||||
let (area, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
|
||||
let bar = Rect::from_min_max(pos2(area.min.x + 8.0, area.min.y + 22.0), pos2(area.max.x - 8.0, area.min.y + 48.0));
|
||||
@@ -416,6 +488,10 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
if r.clicked() {
|
||||
answer = Some(true);
|
||||
}
|
||||
let mut preview = app.dialog_state.gradient.preview;
|
||||
let r = ui.checkbox(&mut preview, tr("Preview"));
|
||||
app.auto.add("dialog.gradient.preview", r.rect, "Preview");
|
||||
app.dialog_state.gradient.preview = preview;
|
||||
});
|
||||
});
|
||||
// Delete / Backspace remove the selected stop, but not while typing in a field.
|
||||
@@ -427,12 +503,8 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
app.dialog_state.gradient.remove(track, id);
|
||||
}
|
||||
match answer {
|
||||
Some(true) => commit(app, ctx),
|
||||
Some(false) => {
|
||||
app.dialog = None;
|
||||
app.dialog_state.gradient = Draft::default();
|
||||
}
|
||||
None => {}
|
||||
Some(ok) => finish(app, ok, ctx),
|
||||
None => sync_preview(app, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ pub struct LayerStyleState {
|
||||
checkpoint: Option<Checkpoint>,
|
||||
}
|
||||
|
||||
/// The project and undo history as a dialog found them, for a Cancel that rolls its live edits back.
|
||||
#[derive(Clone)]
|
||||
struct Checkpoint {
|
||||
project: std::sync::Arc<effectcraft_engine::project::Project>,
|
||||
history: effectcraft_engine::history::History,
|
||||
pub(crate) struct Checkpoint {
|
||||
pub(crate) project: std::sync::Arc<effectcraft_engine::project::Project>,
|
||||
pub(crate) history: effectcraft_engine::history::History,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Checkpoint {
|
||||
@@ -36,7 +37,7 @@ impl std::fmt::Debug for Checkpoint {
|
||||
}
|
||||
}
|
||||
|
||||
fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint {
|
||||
pub(crate) fn checkpoint(app: &mut EffectcraftApp) -> Checkpoint {
|
||||
app.session.history.merge_key = None;
|
||||
Checkpoint { project: app.session.project.clone(), history: app.session.history.clone() }
|
||||
}
|
||||
@@ -258,25 +259,32 @@ pub fn finish(app: &mut EffectcraftApp, ok: bool) {
|
||||
if let Some(checkpoint) = app.dialog_state.layer_style.checkpoint.take()
|
||||
&& !ok
|
||||
{
|
||||
let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project);
|
||||
if changed {
|
||||
// Snapshot only when Cancel has something to restore: opening and cancelling without
|
||||
// edits must not copy the whole TextDoc.
|
||||
let edited = active_editor_snapshot(&app.session);
|
||||
app.session.project = checkpoint.project;
|
||||
app.session.history = checkpoint.history;
|
||||
app.session.sanitize_after_replacement(edited.as_ref());
|
||||
app.session.bump();
|
||||
} else {
|
||||
// No edit was committed, so nothing can have become invalid. In particular, do not
|
||||
// call sanitize_state here: its text-edit cleanup clones the whole TextDoc.
|
||||
app.session.history = checkpoint.history;
|
||||
}
|
||||
restore(app, checkpoint);
|
||||
}
|
||||
app.session.history.merge_key = None;
|
||||
app.dialog = None;
|
||||
}
|
||||
|
||||
/// Put the project and undo history back as `checkpoint` has them (nothing happens when nothing
|
||||
/// changed since).
|
||||
pub(crate) fn restore(app: &mut EffectcraftApp, checkpoint: Checkpoint) {
|
||||
let changed = !std::sync::Arc::ptr_eq(&app.session.project, &checkpoint.project);
|
||||
if changed {
|
||||
// Snapshot only when Cancel has something to restore: opening and cancelling without
|
||||
// edits must not copy the whole TextDoc.
|
||||
let edited = active_editor_snapshot(&app.session);
|
||||
app.session.project = checkpoint.project;
|
||||
app.session.history = checkpoint.history;
|
||||
app.session.sanitize_after_replacement(edited.as_ref());
|
||||
app.session.bump();
|
||||
} else {
|
||||
// No edit was committed, so nothing can have become invalid. In particular, do not
|
||||
// call sanitize_state here: its text-edit cleanup clones the whole TextDoc.
|
||||
app.session.history = checkpoint.history;
|
||||
}
|
||||
app.session.history.merge_key = None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Preview for the settings dialogs that edit something that already exists (Solid Settings on a
|
||||
//! solid layer, Camera Settings, Light Settings, Composition Settings): After Effects' Preview
|
||||
//! checkbox. Each change of the draft is applied to the project at once, so the canvas shows it
|
||||
//! while it is made; OK keeps it and Cancel and Escape take it back.
|
||||
//!
|
||||
//! Every step goes back to the checkpoint taken when the dialog opened (the project and its undo
|
||||
//! history) and then runs the dialog's command once with the draft's parameters. So the project is
|
||||
//! always "as it opened, plus one command": OK leaves exactly one undo step, whatever was tried
|
||||
//! meanwhile, and Cancel only has to put the checkpoint back. With Preview off nothing is applied
|
||||
//! until OK, and switching it off takes back what was applied.
|
||||
//!
|
||||
//! While a preview is active the dialog opens at the lower right of the window, without the dark
|
||||
//! backdrop, and the window behind it is not greyed out, so the canvas stays in view.
|
||||
|
||||
use egui::vec2;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::panels::dialogs::Placement;
|
||||
use crate::panels::layer_styles_dialog::{Checkpoint, checkpoint};
|
||||
use crate::panels::preview_merge::{self, Applied};
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Where a previewing dialog opens: the lower right, undimmed.
|
||||
pub const PLACE: Placement = Placement { start: vec2(0.75, 0.8), dim: false };
|
||||
|
||||
/// The state of the one dialog being previewed (only one dialog is open at a time).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LivePreview {
|
||||
/// The dialog this belongs to; a preview left over from another one is ignored.
|
||||
dialog: Option<Dialog>,
|
||||
/// The Preview checkbox.
|
||||
pub on: bool,
|
||||
checkpoint: Option<Checkpoint>,
|
||||
/// The draft's parameters when the dialog opened: nothing is applied while it equals them.
|
||||
baseline: Option<Value>,
|
||||
/// The command and parameters the preview last applied.
|
||||
applied: Option<(&'static str, Value)>,
|
||||
/// The project as that command left it, to tell edits made since from the preview's.
|
||||
applied_at: Option<Applied>,
|
||||
}
|
||||
|
||||
impl LivePreview {
|
||||
fn active_for(&self, dialog: Option<Dialog>) -> bool {
|
||||
self.checkpoint.is_some() && self.dialog.is_some() && self.dialog == dialog
|
||||
}
|
||||
}
|
||||
|
||||
/// Start previewing for `dialog`, which has just opened on an existing item (Preview on).
|
||||
pub fn begin(app: &mut EffectcraftApp, dialog: Dialog) {
|
||||
app.dialog_state.live = LivePreview { dialog: Some(dialog), on: true, checkpoint: Some(checkpoint(app)), ..Default::default() };
|
||||
}
|
||||
|
||||
/// The dialog that is open is previewing.
|
||||
pub fn active(app: &EffectcraftApp) -> bool {
|
||||
app.dialog_state.live.active_for(app.dialog)
|
||||
}
|
||||
|
||||
/// Where the open dialog goes: the lower right, undimmed, while it previews; else the middle.
|
||||
pub fn place(app: &EffectcraftApp) -> Placement {
|
||||
if active(app) { PLACE } else { Placement::default() }
|
||||
}
|
||||
|
||||
/// Keep the project in step with the draft: `command` with `params` is what OK would run. The
|
||||
/// first call (the draft as it opened) only notes it.
|
||||
pub fn sync(app: &mut EffectcraftApp, command: &'static str, params: Value) {
|
||||
if !active(app) {
|
||||
return;
|
||||
}
|
||||
let live = &app.dialog_state.live;
|
||||
if live.baseline.is_none() {
|
||||
app.dialog_state.live.baseline = Some(params);
|
||||
return;
|
||||
}
|
||||
let want = live.on && live.baseline.as_ref() != Some(¶ms);
|
||||
let is_applied = live.applied.is_some();
|
||||
if want {
|
||||
if live.applied.as_ref().is_some_and(|(c, p)| *c == command && *p == params) {
|
||||
return;
|
||||
}
|
||||
if is_applied {
|
||||
put_back(app);
|
||||
}
|
||||
match app.session.execute(command, params.clone()) {
|
||||
Ok(_) => {
|
||||
app.dialog_state.live.applied = Some((command, params));
|
||||
app.dialog_state.live.applied_at = Some(preview_merge::applied(app));
|
||||
}
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
} else if is_applied {
|
||||
put_back(app);
|
||||
}
|
||||
}
|
||||
|
||||
/// The project as the dialog found it.
|
||||
fn put_back(app: &mut EffectcraftApp) {
|
||||
if let Some(cp) = app.dialog_state.live.checkpoint.clone() {
|
||||
let at = app.dialog_state.live.applied_at.take();
|
||||
// Only what the preview did is taken back: an edit made while the dialog was open stays.
|
||||
preview_merge::rollback(app, cp, at.as_ref());
|
||||
}
|
||||
app.dialog_state.live.applied = None;
|
||||
app.dialog_state.live.applied_at = None;
|
||||
}
|
||||
|
||||
/// OK: the preview's result is the edit. The preview is first brought up to `command` with
|
||||
/// `params`, the draft as OK found it (an edit made in the same frame as OK is not in the last
|
||||
/// preview). Returns whether the project then already has the edit (the caller must not run the
|
||||
/// command again); otherwise the caller runs it, as without a preview.
|
||||
pub fn keep(app: &mut EffectcraftApp, command: &'static str, params: Value) -> bool {
|
||||
sync(app, command, params);
|
||||
let kept = active(app) && app.dialog_state.live.applied.is_some();
|
||||
app.dialog_state.live = LivePreview::default();
|
||||
kept
|
||||
}
|
||||
|
||||
/// Cancel and Escape: take back whatever the preview applied.
|
||||
pub fn cancel(app: &mut EffectcraftApp) {
|
||||
if active(app) {
|
||||
put_back(app);
|
||||
}
|
||||
app.dialog_state.live = LivePreview::default();
|
||||
}
|
||||
|
||||
/// The Preview checkbox, when the open dialog previews; `id` is its automation id.
|
||||
pub fn checkbox(ui: &mut egui::Ui, auto: &mut crate::automation::Registry, on: &mut bool, id: &str) {
|
||||
let r = ui.checkbox(on, crate::i18n::tr("Preview"));
|
||||
auto.add(id, r.rect, "Preview");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Taking a dialog's Preview back without taking other edits with it.
|
||||
//!
|
||||
//! A previewing dialog (Camera, Light, Solid, Composition Settings, the Gradient Editor) puts the
|
||||
//! project and undo history back to a checkpoint when it is cancelled or Preview goes off. If the
|
||||
//! project changed in the meantime for another reason (a camera analysis finishing, a script, the
|
||||
//! control channel), swapping the checkpoint in would throw that edit away. This module undoes
|
||||
//! only what the preview did: a three-way merge of the checkpoint (before the preview), the project
|
||||
//! right after the preview (`Applied`) and the project now. Whatever the preview did not touch
|
||||
//! stays as it is now; what only the preview touched goes back to the checkpoint; what both touched
|
||||
//! keeps the other edit.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_engine::commands::text_edit::active_editor_snapshot;
|
||||
use effectcraft_engine::project::{Comp, Item, ItemId, ItemKind, Layer, LayerId, Project};
|
||||
|
||||
use crate::EffectcraftApp;
|
||||
use crate::panels::layer_styles_dialog::{Checkpoint, restore};
|
||||
|
||||
/// The project right after a preview was applied, and the undo depth then.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Applied {
|
||||
project: Arc<Project>,
|
||||
undo_len: usize,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Applied {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Applied").field("undo_len", &self.undo_len).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Note the project as the preview that has just been applied left it.
|
||||
pub(crate) fn applied(app: &EffectcraftApp) -> Applied {
|
||||
Applied { project: app.session.project.clone(), undo_len: app.session.history.undo.len() }
|
||||
}
|
||||
|
||||
/// Put the project back as `checkpoint` has it, minus nothing but the preview: when the project is
|
||||
/// still what the preview made (or nothing says what that was) the checkpoint is simply restored.
|
||||
pub(crate) fn rollback(app: &mut EffectcraftApp, checkpoint: Checkpoint, applied: Option<&Applied>) {
|
||||
let Some(applied) = applied.filter(|a| !Arc::ptr_eq(&a.project, &app.session.project)) else {
|
||||
restore(app, checkpoint);
|
||||
return;
|
||||
};
|
||||
let merged = merge(&checkpoint.project, &applied.project, &app.session.project);
|
||||
// One step for what was done meanwhile, from the project before the dialog: Undo takes it back.
|
||||
let label = app.session.history.undo.get(applied.undo_len).map(|(label, _)| label.clone()).unwrap_or_else(|| "Edit".to_string());
|
||||
let mut history = checkpoint.history;
|
||||
history.undo.push((label, checkpoint.project));
|
||||
history.redo.clear();
|
||||
history.merge_key = None;
|
||||
let edited = active_editor_snapshot(&app.session);
|
||||
app.session.project = Arc::new(merged);
|
||||
app.session.history = history;
|
||||
app.session.sanitize_after_replacement(edited.as_ref());
|
||||
app.session.bump();
|
||||
}
|
||||
|
||||
/// `now` without the changes between `before` and `after`, keeping any other change.
|
||||
fn merge(before: &Project, after: &Project, now: &Project) -> Project {
|
||||
let mut merged = now.clone();
|
||||
let ids: BTreeSet<ItemId> = before.items.keys().chain(after.items.keys()).chain(now.items.keys()).copied().collect();
|
||||
for id in ids {
|
||||
match item(before.items.get(&id), after.items.get(&id), now.items.get(&id)) {
|
||||
Some(it) => {
|
||||
merged.items.insert(id, it);
|
||||
}
|
||||
None => {
|
||||
merged.items.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
/// The three-way choice for one value: untouched by the preview keeps `now`, touched only by the
|
||||
/// preview goes back to `before`, touched by both is `both`.
|
||||
fn pick<T: PartialEq + Clone>(before: Option<&T>, after: Option<&T>, now: Option<&T>, both: impl FnOnce() -> Option<T>) -> Option<T> {
|
||||
if after == before {
|
||||
now.cloned()
|
||||
} else if now == after {
|
||||
before.cloned()
|
||||
} else {
|
||||
both()
|
||||
}
|
||||
}
|
||||
|
||||
fn item(before: Option<&Item>, after: Option<&Item>, now: Option<&Item>) -> Option<Item> {
|
||||
pick(before, after, now, || {
|
||||
if let (Some(b), Some(a), Some(n)) = (before, after, now)
|
||||
&& let (ItemKind::Comp(bc), ItemKind::Comp(ac), ItemKind::Comp(nc)) = (&b.kind, &a.kind, &n.kind)
|
||||
{
|
||||
let mut merged = n.clone();
|
||||
// Name, folder and the like: only a preview that changed the item itself and nothing else
|
||||
// is taken back; the comp's own contents are merged below.
|
||||
merged.kind = ItemKind::Comp(Arc::new(comp(bc, ac, nc)));
|
||||
return Some(merged);
|
||||
}
|
||||
now.cloned()
|
||||
})
|
||||
}
|
||||
|
||||
fn bare(c: &Comp) -> Comp {
|
||||
let mut b = c.clone();
|
||||
b.layers.clear();
|
||||
b
|
||||
}
|
||||
|
||||
fn layer_of(c: &Comp, id: LayerId) -> Option<&Layer> {
|
||||
c.layers.iter().find(|l| l.id == id)
|
||||
}
|
||||
|
||||
fn comp(before: &Comp, after: &Comp, now: &Comp) -> Comp {
|
||||
// The comp's own settings, apart from its layers.
|
||||
let mut merged = pick(Some(&bare(before)), Some(&bare(after)), Some(&bare(now)), || Some(bare(now))).unwrap_or_else(|| bare(now));
|
||||
let merge_layer = |id: LayerId| pick(layer_of(before, id), layer_of(after, id), layer_of(now, id), || layer_of(now, id).cloned());
|
||||
// Layers as they stand now, in their order, then the ones the preview removed put back.
|
||||
let mut layers: Vec<Layer> = now.layers.iter().filter_map(|l| merge_layer(l.id)).collect();
|
||||
for (at, l) in before.layers.iter().enumerate() {
|
||||
if layer_of(now, l.id).is_none()
|
||||
&& let Some(back) = merge_layer(l.id)
|
||||
{
|
||||
layers.insert(at.min(layers.len()), back);
|
||||
}
|
||||
}
|
||||
merged.layers = layers;
|
||||
merged
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Dialog;
|
||||
use crate::panels::live_preview;
|
||||
use effectcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn app_with_light() -> (EffectcraftApp, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Scene", "width": 320, "height": 180, "duration": 1})).unwrap();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap();
|
||||
(EffectcraftApp::new(s), light)
|
||||
}
|
||||
|
||||
fn name_of(app: &EffectcraftApp, layer: u64) -> String {
|
||||
app.session.active_comp().unwrap().layer(LayerId(layer)).unwrap().name.clone()
|
||||
}
|
||||
|
||||
fn layer_count(app: &EffectcraftApp) -> usize {
|
||||
app.session.active_comp().unwrap().layers.len()
|
||||
}
|
||||
|
||||
/// The preview renames the light; a null is added meanwhile (what a finished background job
|
||||
/// would do). Cancel takes the rename back and keeps the null, with one undo step for it.
|
||||
#[test]
|
||||
fn cancelling_a_preview_keeps_an_edit_made_while_it_was_open() {
|
||||
let (mut app, light) = app_with_light();
|
||||
let original = name_of(&app, light);
|
||||
app.dialog = Some(Dialog::LightSettings);
|
||||
live_preview::begin(&mut app, Dialog::LightSettings);
|
||||
let rename = |name: &str| json!({"layer": light, "name": name});
|
||||
live_preview::sync(&mut app, "layer.rename", rename(&original));
|
||||
live_preview::sync(&mut app, "layer.rename", rename("Previewed"));
|
||||
assert_eq!(name_of(&app, light), "Previewed");
|
||||
let layers = layer_count(&app);
|
||||
app.session.execute("layer.newNull", json!({})).unwrap();
|
||||
live_preview::cancel(&mut app);
|
||||
assert_eq!(name_of(&app, light), original, "the preview is taken back");
|
||||
assert_eq!(layer_count(&app), layers + 1, "the other edit stays");
|
||||
assert!(app.session.undo());
|
||||
assert_eq!(layer_count(&app), layers, "and has its own undo step");
|
||||
assert_eq!(name_of(&app, light), original);
|
||||
}
|
||||
|
||||
/// With nothing else done meanwhile the checkpoint is restored as before.
|
||||
#[test]
|
||||
fn cancelling_an_undisturbed_preview_restores_the_checkpoint() {
|
||||
let (mut app, light) = app_with_light();
|
||||
let original = name_of(&app, light);
|
||||
let steps = app.session.history.undo.len();
|
||||
app.dialog = Some(Dialog::LightSettings);
|
||||
live_preview::begin(&mut app, Dialog::LightSettings);
|
||||
live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original}));
|
||||
live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"}));
|
||||
live_preview::cancel(&mut app);
|
||||
assert_eq!(name_of(&app, light), original);
|
||||
assert_eq!(app.session.history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// An item both the preview and another edit changed keeps the other edit.
|
||||
#[test]
|
||||
fn a_layer_both_edits_changed_keeps_the_other_edit() {
|
||||
let (mut app, light) = app_with_light();
|
||||
let original = name_of(&app, light);
|
||||
app.dialog = Some(Dialog::LightSettings);
|
||||
live_preview::begin(&mut app, Dialog::LightSettings);
|
||||
live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": original}));
|
||||
live_preview::sync(&mut app, "layer.rename", json!({"layer": light, "name": "Previewed"}));
|
||||
app.session.execute("layer.rename", json!({"layer": light, "name": "Renamed elsewhere"})).unwrap();
|
||||
live_preview::cancel(&mut app);
|
||||
assert_eq!(name_of(&app, light), "Renamed elsewhere");
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod ui_character_eyedropper;
|
||||
mod ui_color_hex;
|
||||
mod ui_comp_presets;
|
||||
mod ui_control_modifiers;
|
||||
mod ui_dialog_preview;
|
||||
mod ui_dock_resize;
|
||||
mod ui_drag_drop;
|
||||
mod ui_effect_controls;
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Preview in the settings dialogs that edit something that exists (After Effects' Preview
|
||||
//! checkbox): Solid Settings on a solid layer, Camera Settings, Light Settings and Composition
|
||||
//! Settings. A change of the draft reaches the project at once, so the canvas shows it while it is
|
||||
//! made; OK keeps it in one undo step, Cancel and Escape take it back, and with Preview off nothing
|
||||
//! is applied until OK. The window behind a previewing dialog is not dimmed, and the dialog opens
|
||||
//! at the lower right; the same dialogs creating something new keep the middle.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::{ItemKind, LayerId, Resolution};
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp, menus};
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn harness(setup: impl FnOnce(&mut Session)) -> Harness<'static, EffectcraftApp> {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 10})).unwrap();
|
||||
setup(&mut s);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn open(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) {
|
||||
let ctx = h.ctx.clone();
|
||||
menus::invoke(h.state_mut(), &ctx, id, params).unwrap();
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn center(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 {
|
||||
let r = h.state().auto.find(id).unwrap_or_else(|| panic!("no automation id {id}")).rect;
|
||||
pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
|
||||
}
|
||||
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = center(h, id);
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn escape(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.input_mut().events.push(Event::Key { key: Key::Escape, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
fn undo_len(h: &Harness<'_, EffectcraftApp>) -> usize {
|
||||
h.state().session.history.undo.len()
|
||||
}
|
||||
|
||||
fn solid_size(h: &Harness<'_, EffectcraftApp>, layer: u64) -> [u32; 2] {
|
||||
let s = &h.state().session;
|
||||
let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
let effectcraft_engine::project::LayerSource::Solid { item } = l.source else { panic!("not a solid") };
|
||||
match &s.project.item(item).unwrap().kind {
|
||||
ItemKind::Solid(so) => [so.width, so.height],
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn scalar(h: &Harness<'_, EffectcraftApp>, layer: u64, path: &str) -> f64 {
|
||||
let s = &h.state().session;
|
||||
let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
l.props.prop(path).unwrap().value_at(l.layer_time(s.time())).as_f64()
|
||||
}
|
||||
|
||||
fn comp_width(h: &Harness<'_, EffectcraftApp>) -> u32 {
|
||||
h.state().session.active_comp().unwrap().width
|
||||
}
|
||||
|
||||
fn solid_setup(s: &mut Session) {
|
||||
s.execute("layer.newSolid", json!({"name": "Box", "color": "#ff0000", "width": 100, "height": 100})).unwrap();
|
||||
}
|
||||
|
||||
fn solid_layer(h: &Harness<'_, EffectcraftApp>) -> u64 {
|
||||
h.state().session.active_comp().unwrap().layers[0].id.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_settings_previews_live_and_cancel_and_escape_take_it_back() {
|
||||
let mut h = harness(solid_setup);
|
||||
let l = solid_layer(&h);
|
||||
let undo = undo_len(&h);
|
||||
open(&mut h, "layer.settings", json!({"layer": l}));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::SolidSettings));
|
||||
assert!(h.state().auto.find("dialog.solid.preview").is_some(), "a Preview checkbox");
|
||||
h.state_mut().dialog_state_mut().solid_size = [200, 150];
|
||||
h.run_steps(3);
|
||||
assert_eq!(solid_size(&h, l), [200, 150], "the canvas shows the new size before OK");
|
||||
h.state_mut().dialog_state_mut().solid_size = [240, 120];
|
||||
h.run_steps(3);
|
||||
assert_eq!(solid_size(&h, l), [240, 120]);
|
||||
assert_eq!(undo_len(&h), undo + 1, "however much was tried, the project is the original plus one command");
|
||||
click(&mut h, "dialog.solid.cancel");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(solid_size(&h, l), [100, 100], "Cancel puts it back");
|
||||
assert_eq!(undo_len(&h), undo, "and leaves no undo step");
|
||||
// Escape does the same.
|
||||
open(&mut h, "layer.settings", json!({"layer": l}));
|
||||
h.state_mut().dialog_state_mut().solid_size = [200, 150];
|
||||
h.run_steps(3);
|
||||
assert_eq!(solid_size(&h, l), [200, 150]);
|
||||
escape(&mut h);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(solid_size(&h, l), [100, 100]);
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_settings_ok_keeps_the_preview_as_one_undo_step() {
|
||||
let mut h = harness(solid_setup);
|
||||
let l = solid_layer(&h);
|
||||
let undo = undo_len(&h);
|
||||
open(&mut h, "layer.settings", json!({"layer": l}));
|
||||
h.state_mut().dialog_state_mut().solid_size = [200, 150];
|
||||
h.run_steps(3);
|
||||
h.state_mut().dialog_state_mut().solid_size = [220, 130];
|
||||
h.run_steps(3);
|
||||
click(&mut h, "dialog.solid.ok");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(solid_size(&h, l), [220, 130]);
|
||||
assert_eq!(undo_len(&h), undo + 1, "one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(solid_size(&h, l), [100, 100], "undone in one go");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_preview_off_nothing_is_applied_until_ok() {
|
||||
let mut h = harness(solid_setup);
|
||||
let l = solid_layer(&h);
|
||||
let undo = undo_len(&h);
|
||||
open(&mut h, "layer.settings", json!({"layer": l}));
|
||||
h.state_mut().dialog_state_mut().solid_size = [200, 150];
|
||||
h.run_steps(3);
|
||||
assert_eq!(solid_size(&h, l), [200, 150]);
|
||||
click(&mut h, "dialog.solid.preview");
|
||||
assert!(!h.state().dialog_state().live.on);
|
||||
assert_eq!(solid_size(&h, l), [100, 100], "switching Preview off takes the preview back");
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
h.state_mut().dialog_state_mut().solid_size = [260, 140];
|
||||
h.run_steps(3);
|
||||
assert_eq!(solid_size(&h, l), [100, 100], "nothing is applied while editing");
|
||||
click(&mut h, "dialog.solid.ok");
|
||||
assert_eq!(solid_size(&h, l), [260, 140], "OK applies the draft");
|
||||
assert_eq!(undo_len(&h), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_settings_preview() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("layer.newCamera", json!({})).unwrap();
|
||||
});
|
||||
let l = solid_layer(&h);
|
||||
let before = scalar(&h, l, "cameraOptions/zoom");
|
||||
let undo = undo_len(&h);
|
||||
open(&mut h, "layer.cameraSettings", json!({"layer": l}));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::CameraSettings));
|
||||
assert!(h.state().auto.find("dialog.camera.preview").is_some());
|
||||
h.state_mut().dialog_state_mut().camera.zoom = before + 500.0;
|
||||
h.run_steps(3);
|
||||
assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 500.0)).abs() < 1e-6, "the zoom changes live");
|
||||
assert_eq!(undo_len(&h), undo + 1);
|
||||
click(&mut h, "dialog.camera.cancel");
|
||||
assert!((scalar(&h, l, "cameraOptions/zoom") - before).abs() < 1e-6, "Cancel puts the zoom back");
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
open(&mut h, "layer.cameraSettings", json!({"layer": l}));
|
||||
h.state_mut().dialog_state_mut().camera.zoom = before + 300.0;
|
||||
h.run_steps(3);
|
||||
click(&mut h, "dialog.camera.ok");
|
||||
assert!((scalar(&h, l, "cameraOptions/zoom") - (before + 300.0)).abs() < 1e-6, "OK keeps it");
|
||||
assert_eq!(undo_len(&h), undo + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_settings_preview() {
|
||||
let mut h = harness(|s| {
|
||||
s.execute("layer.newLight", json!({"kind": "Point"})).unwrap();
|
||||
});
|
||||
let l = solid_layer(&h);
|
||||
let before = scalar(&h, l, "lightOptions/intensity");
|
||||
let undo = undo_len(&h);
|
||||
open(&mut h, "layer.lightSettings", json!({"layer": l}));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::LightSettings));
|
||||
assert!(h.state().auto.find("dialog.light.preview").is_some());
|
||||
h.state_mut().dialog_state_mut().light.intensity = before + 37.0;
|
||||
h.run_steps(3);
|
||||
assert!((scalar(&h, l, "lightOptions/intensity") - (before + 37.0)).abs() < 1e-6, "the intensity changes live");
|
||||
escape(&mut h);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert!((scalar(&h, l, "lightOptions/intensity") - before).abs() < 1e-6, "Escape puts it back");
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composition_settings_preview() {
|
||||
let mut h = harness(|_| {});
|
||||
let before = comp_width(&h);
|
||||
let undo = undo_len(&h);
|
||||
open(&mut h, "comp.settings", json!({}));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
|
||||
assert!(h.state().auto.find("dialog.comp.preview").is_some());
|
||||
h.state_mut().dialog_state_mut().comp.width = before + 80;
|
||||
h.run_steps(3);
|
||||
assert_eq!(comp_width(&h), before + 80, "the comp is resized live");
|
||||
click(&mut h, "dialog.comp.cancel");
|
||||
assert_eq!(comp_width(&h), before, "Cancel puts the size back");
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
open(&mut h, "comp.settings", json!({}));
|
||||
h.state_mut().dialog_state_mut().comp.width = before + 40;
|
||||
h.run_steps(3);
|
||||
click(&mut h, "dialog.comp.ok");
|
||||
assert_eq!(comp_width(&h), before + 40, "OK keeps it");
|
||||
assert_eq!(undo_len(&h), undo + 1, "in one undo step");
|
||||
}
|
||||
|
||||
/// The Resolution is no part of the Composition Settings command, so a preview never applies it:
|
||||
/// OK has to, also when it keeps a preview of another field.
|
||||
#[test]
|
||||
fn composition_settings_ok_after_a_preview_still_applies_the_resolution() {
|
||||
let mut h = harness(|_| {});
|
||||
let before = comp_width(&h);
|
||||
open(&mut h, "comp.settings", json!({}));
|
||||
h.state_mut().dialog_state_mut().comp.width = before + 80;
|
||||
h.run_steps(3);
|
||||
assert_eq!(comp_width(&h), before + 80, "the preview applied the width");
|
||||
h.state_mut().dialog_state_mut().comp.resolution = Resolution::Half;
|
||||
h.run_steps(3);
|
||||
click(&mut h, "dialog.comp.ok");
|
||||
assert_eq!(comp_width(&h), before + 80);
|
||||
assert_eq!(h.state().session.active_comp().unwrap().resolution, Resolution::Half, "OK applied the Resolution");
|
||||
}
|
||||
|
||||
/// An edit made in the same frame as OK (Enter) is not in the last preview yet: OK brings the
|
||||
/// project up to the draft it finds instead of keeping the older preview.
|
||||
#[test]
|
||||
fn ok_in_the_frame_of_an_edit_keeps_the_edit_not_the_older_preview() {
|
||||
let mut h = harness(solid_setup);
|
||||
let l = solid_layer(&h);
|
||||
open(&mut h, "layer.settings", json!({"layer": l}));
|
||||
h.state_mut().dialog_state_mut().solid_size = [200, 150];
|
||||
h.run_steps(3);
|
||||
assert_eq!(solid_size(&h, l), [200, 150]);
|
||||
h.state_mut().dialog_state_mut().solid_size = [240, 120];
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(solid_size(&h, l), [240, 120], "OK kept the draft it found, not the earlier preview");
|
||||
}
|
||||
|
||||
/// A previewing dialog opens at the lower right so the canvas stays in view; the same dialogs
|
||||
/// creating something new keep the middle and have no Preview.
|
||||
#[test]
|
||||
fn previewing_dialogs_open_at_the_lower_right_and_new_ones_in_the_middle() {
|
||||
let mut h = harness(solid_setup);
|
||||
let l = solid_layer(&h);
|
||||
open(&mut h, "layer.settings", json!({"layer": l}));
|
||||
let ok = center(&h, "dialog.solid.ok");
|
||||
assert!(ok.x > 800.0 && ok.y > 500.0, "Solid Settings on a layer is in the lower right: {ok:?}");
|
||||
click(&mut h, "dialog.solid.cancel");
|
||||
open(&mut h, "layer.newSolid", json!({}));
|
||||
assert_eq!(h.state().dialog, Some(Dialog::SolidSettings));
|
||||
assert!(h.state().auto.find("dialog.solid.preview").is_none(), "a new solid has no Preview");
|
||||
let ok = center(&h, "dialog.solid.ok");
|
||||
assert!(ok.x < 1000.0, "New Solid stays in the middle: {ok:?}");
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (k as f32 / 6.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// A previewing dialog opens at the lower right, but it can be dragged by its title to wherever
|
||||
/// the canvas needs to be seen, and the preview keeps working from there.
|
||||
#[test]
|
||||
fn a_previewing_dialog_can_be_dragged_by_its_title() {
|
||||
let mut h = harness(solid_setup);
|
||||
let l = solid_layer(&h);
|
||||
open(&mut h, "layer.settings", json!({"layer": l}));
|
||||
let ok = center(&h, "dialog.solid.ok");
|
||||
let title = h.get_by_label("Solid Settings").rect().center();
|
||||
drag(&mut h, title, title + egui::vec2(-500.0, -300.0));
|
||||
let moved = center(&h, "dialog.solid.ok");
|
||||
assert!(moved.x < ok.x - 300.0 && moved.y < ok.y - 150.0, "the dialog followed the drag: {ok:?} -> {moved:?}");
|
||||
// Still previewing, and the position is kept from frame to frame.
|
||||
h.state_mut().dialog_state_mut().solid_size = [200, 150];
|
||||
h.run_steps(3);
|
||||
assert_eq!(solid_size(&h, l), [200, 150]);
|
||||
assert_eq!(center(&h, "dialog.solid.ok"), moved, "it stays where it was put");
|
||||
}
|
||||
@@ -197,7 +197,8 @@ fn stop_edits_ok_sets_the_gradient_in_one_undo_step() {
|
||||
let from = center(&h, "dialog.gradient.opacityStop.1");
|
||||
let to = along(&h, "dialog.gradient.opacityStop.1", 0.5);
|
||||
drag(&mut h, from, to);
|
||||
assert_eq!(undo_len(&h), undo, "nothing is set while editing");
|
||||
assert_eq!(undo_len(&h), undo + 1, "the live preview is one merged undo step while editing");
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the property shows the edit before OK");
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
let g = gradient(&colors_prop(&h, l, colors).value);
|
||||
@@ -403,11 +404,13 @@ fn location_moves_past_a_neighbour_and_the_fields_follow_the_stop() {
|
||||
assert!(!shown.iter().any(|v| (v - 70.0).abs() < 1.0), "{shown:?}");
|
||||
}
|
||||
|
||||
/// A layer deleted while the editor is open: OK reports it, without a crash.
|
||||
/// A layer deleted while the editor is open, Preview off: OK reports the failed set, without a
|
||||
/// crash.
|
||||
#[test]
|
||||
fn a_layer_deleted_while_editing_is_reported() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
open_editor(&mut h, colors);
|
||||
click(&mut h, "dialog.gradient.preview");
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.5);
|
||||
click_at(&mut h, p);
|
||||
h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap();
|
||||
@@ -417,6 +420,98 @@ fn a_layer_deleted_while_editing_is_reported() {
|
||||
assert!(!h.state().ui.status.is_empty(), "the failed set is reported");
|
||||
}
|
||||
|
||||
/// The same with Preview on: the preview's set fails at the next edit and says so, and OK closes
|
||||
/// the editor without a crash.
|
||||
#[test]
|
||||
fn a_layer_deleted_during_the_preview_is_reported_at_the_next_edit() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
open_editor(&mut h, colors);
|
||||
h.state_mut().session.execute("edit.clear", json!({"layers": [l]})).unwrap();
|
||||
h.run_steps(2);
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.5);
|
||||
click_at(&mut h, p);
|
||||
assert!(!h.state().ui.status.is_empty(), "the failed preview set is reported");
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
}
|
||||
|
||||
/// Preview (on by default, as in After Effects): edits reach the property as they are made, in one
|
||||
/// undo step, and Cancel and Escape roll them back to how the editor opened.
|
||||
#[test]
|
||||
fn the_preview_applies_edits_live_and_cancel_rolls_them_back() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
let before = colors_prop(&h, l, colors).value.clone();
|
||||
let undo = undo_len(&h);
|
||||
open_editor(&mut h, colors);
|
||||
assert!(h.state().gradient_draft().preview, "Preview starts on");
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.25);
|
||||
click_at(&mut h, p);
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the new stop shows at once");
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.75);
|
||||
click_at(&mut h, p);
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4);
|
||||
assert_eq!(undo_len(&h), undo + 1, "both edits are one undo step");
|
||||
click(&mut h, "dialog.gradient.cancel");
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(colors_prop(&h, l, colors).value, before, "Cancel puts the gradient back");
|
||||
assert_eq!(undo_len(&h), undo, "and leaves no undo step");
|
||||
// Escape does the same.
|
||||
open_editor(&mut h, colors);
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.5);
|
||||
click_at(&mut h, p);
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3);
|
||||
key(&mut h, Key::Escape);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(colors_prop(&h, l, colors).value, before);
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
}
|
||||
|
||||
/// An edit made in the same frame as OK is not in the last preview: OK sets the draft it finds.
|
||||
#[test]
|
||||
fn ok_in_the_frame_of_an_edit_sets_the_draft_not_the_older_preview() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
open_editor(&mut h, colors);
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.25);
|
||||
click_at(&mut h, p);
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "the preview has three stops");
|
||||
h.state_mut().gradient_draft_mut().add(Track::Color, 0.75);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::panels::gradient_editor::finish(h.state_mut(), true, &ctx);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft it found");
|
||||
}
|
||||
|
||||
/// With Preview off nothing is set until OK, and switching it off takes back what it applied.
|
||||
#[test]
|
||||
fn with_preview_off_nothing_is_set_until_ok() {
|
||||
let (mut h, l, colors) = setup(&[]);
|
||||
let before = colors_prop(&h, l, colors).value.clone();
|
||||
let undo = undo_len(&h);
|
||||
open_editor(&mut h, colors);
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.25);
|
||||
click_at(&mut h, p);
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 3, "previewed");
|
||||
click(&mut h, "dialog.gradient.preview");
|
||||
assert!(!h.state().gradient_draft().preview);
|
||||
assert_eq!(colors_prop(&h, l, colors).value, before, "switching Preview off takes the preview back");
|
||||
assert_eq!(undo_len(&h), undo);
|
||||
let p = along(&h, "dialog.gradient.addColor", 0.75);
|
||||
click_at(&mut h, p);
|
||||
assert_eq!(colors_prop(&h, l, colors).value, before, "nothing is set while editing");
|
||||
click(&mut h, "dialog.gradient.ok");
|
||||
assert_eq!(gradient(&colors_prop(&h, l, colors).value).colors.len(), 4, "OK sets the draft");
|
||||
assert_eq!(undo_len(&h), undo + 1, "in one undo step");
|
||||
}
|
||||
|
||||
/// The editor opens at the lower right of the window, so the canvas it edits stays in view.
|
||||
#[test]
|
||||
fn the_editor_opens_at_the_lower_right() {
|
||||
let (mut h, _, colors) = setup(&[]);
|
||||
open_editor(&mut h, colors);
|
||||
let ok = center(&h, "dialog.gradient.ok");
|
||||
assert!(ok.x > 800.0 && ok.y > 500.0, "the editor's OK button is in the lower right of the 1600 × 1000 window: {ok:?}");
|
||||
}
|
||||
|
||||
/// `prop.editGradient` by id (agents, MCP, the control channel) is an error for a target that
|
||||
/// isn't a gradient property, from the engine and through the UI, and opens nothing.
|
||||
#[test]
|
||||
|
||||
@@ -274,12 +274,13 @@ fn comp_context_menu_opens_that_comps_settings() {
|
||||
assert_eq!(h.state().dialog, Some(Dialog::CompSettings));
|
||||
assert_eq!(h.state().session.active_comp_id(), Some(main), "the viewer keeps its comp");
|
||||
// A new name, then OK: Side's own size goes back to Side under it. Main's values in the
|
||||
// dialog, or OK writing to the viewer's comp, would resize or rename the wrong one.
|
||||
// dialog, or OK writing to the viewer's comp, would resize or rename the wrong one. The
|
||||
// dialog previews the name as it is typed; OK keeps that as one undo step.
|
||||
let undo = h.state().session.history.undo.len();
|
||||
click(&mut h, "dialog.comp.name");
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND });
|
||||
h.input_mut().events.push(Event::Text("Side 2".into()));
|
||||
h.run_steps(2);
|
||||
let undo = h.state().session.history.undo.len();
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None, "OK closed the dialog");
|
||||
|
||||
Reference in New Issue
Block a user