Files
effectcraft/crates/ui-egui/src/panels/dialogs.rs
T
bb2d2005ce Layer: Un-precompose a selected pre-comp layer back into its parent composition (#3081)
* Layer: Un-precompose a selected pre-comp layer back into its parent composition (#2918)

layer.unprecompose copies the nested composition's layers into the parent at the pre-comp
layer's place with timing and position carried over, and refuses what it can't keep exactly.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Un-precompose: refuse a pre-comp layer with Solo or Guide on

A soloed pre-comp layer hides the comp's other layers and a guide layer is
left out of renders; the expanded copies carry neither switch, so the picture
would change. Listed with the other blockers, in the command description and
the agents.md Cookbook entry; the refusal test covers both switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn instead of refusing, only a locked layer stops it

A locked pre-comp layer is still refused, with or without `force`. Every
other former refusal reason is a warning: without `force`,
`layer.unprecompose` returns an error that lists the reasons and changes
nothing. `dryRun` returns `{dryRun, warnings}`. With `force` the layer is
unpacked in one undo step and the result carries `warnings`. Whatever the
pre-comp layer itself carried is dropped, and other layers' parent, track
matte and layer-property links to it are removed.

Layer > Un-precompose in the UI lists the reasons in a prompt with
Unpack Anyway / Cancel (`dialog.unprecompose.force` /
`dialog.unprecompose.cancel`). A layer without reasons unpacks at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn about expressions that refer to the pre-comp layer

An expression on another layer of the comp that names the pre-comp layer
would no longer find it once the layer is unpacked. The expression can
name it in quotes or call `layer(<index>)` with its index, bare or on
thisComp; another comp's comp("…").layer(…) is not counted. Each such
property is now listed once as a warning. Like the other warnings,
`force` unpacks and `dryRun` lists it. The expressions are left as
written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn about lost frame sampling and Position expressions, keep markers in order

Review follow-up (CodeRabbit on #3081):
- A nested comp that preserves a frame rate other than the parent's
  samples its layers on its own frames, and the expanded copies would
  lose that. Motion-blurred nested layers under a different shutter
  would also blur with the parent's shutter. Both are now warnings.
- When the pre-comp layer's Position differs from its Anchor Point, the
  move is added to the stored Position of the unparented nested layers.
  An unparented layer whose Position, or a separated axis, has an
  expression may ignore that move, so it is now a warning.
- Copied comp markers are sorted into time order with the comp's own.
- New tests: a 10 fps nested comp with Preserve Frame Rate in a 30 fps
  comp, a different shutter, a Position expression with and without a
  move, and nested matte and layer-value remapping with shifted markers
  merged in order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: warn when an expression's layer numbers shift; the prompt keeps its target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Un-precompose: a shorter renumbering warning

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
2026-11-07 00:56:54 +01:00

1094 lines
54 KiB
Rust

//! Modal dialogs: About (community links, Contributors and Models credits), New Composition /
//! Composition Settings, Solid Settings and the command palette (Camera/Light Settings live in `dialogs_3d`).
use crate::i18n::{tr, tr_args};
use effectcraft_engine::project::{ItemId, ItemKind, LayerId, LayerSource};
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::{Value, json};
pub use super::forms::{Field, form, info, open_form};
use crate::icons::{self, Icon};
use crate::theme::Tokens;
use crate::{Dialog, EffectcraftApp};
pub use super::comp_settings::CompDraft;
#[derive(Clone, Debug, Default)]
pub struct DialogState {
pub comp: CompDraft,
/// The settings of the last composition made with New Composition (the next one starts
/// from them, as in After Effects).
pub last_new_comp: Option<CompDraft>,
pub editing_existing: bool,
/// The composition Composition Settings edits (the Project panel's row menu can pick one
/// the viewer doesn't show).
pub editing_comp: Option<ItemId>,
pub solid_name: String,
pub solid_color: [f32; 3],
pub solid_size: [u32; 2],
pub solid_pixel_aspect: f64,
/// Solid Settings edits this layer's solid (Layer ▸ Layer Settings) instead of making one.
pub solid_layer: Option<u64>,
/// Other layers use the edited solid too.
pub solid_shared: bool,
/// "Affect all layers that use this solid" (off: the layer gets its own copy).
pub solid_affect_all: bool,
pub palette_query: String,
pub palette_sel: usize,
/// Settings dialog page id (`general`, `appearance`…).
pub settings_page: String,
/// Settings ▸ Plug-in Library: the search field.
pub library_query: String,
/// Settings ▸ Plug-in Library: the package whose preview is open.
pub library_preview: Option<String>,
/// The preview's Before/After split (0 = all After, 1 = all Before).
pub library_split: f32,
/// Settings when the dialog opened (Cancel restores them).
pub prefs_snapshot: Option<effectcraft_engine::prefs::Prefs>,
/// Keyboard Shortcuts editor.
pub shortcuts: super::shortcut_editor::EditorState,
/// The open parameter form.
pub form: super::forms::Form,
/// Message box (title, body).
pub info: (String, String),
pub camera: super::dialogs_3d::CameraDraft,
pub light: super::dialogs_3d::LightDraft,
pub primitive: super::primitive_dialog::PrimitiveDraft,
pub velocity: super::key_dialogs::VelocityDraft,
pub interp: super::key_dialogs::InterpDraft,
pub stretch: super::key_dialogs::StretchDraft,
pub track_options: super::tracker::OptionsDraft,
pub track_target: super::tracker::TargetDraft,
pub track_apply: super::tracker::ApplyDraft,
/// Composition/Layer Marker dialog.
pub marker: super::markers_ui::MarkerDraft,
/// Pre-compose dialog.
pub precompose: super::precomp::PrecomposeDraft,
/// Layer Style dialog.
pub layer_style: super::layer_styles_dialog::LayerStyleState,
/// The unsaved-changes prompt and the command it holds.
pub unsaved: super::unsaved::Pending,
/// Deleting Project items in use: the deletion waiting on the prompt.
pub delete_items: super::delete_items::Pending,
pub aep_losses: super::aep_losses::Pending,
/// Un-precompose with reasons: the unpack waiting on the prompt.
pub unprecompose: super::unprecompose::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)>,
}
pub fn open_new_comp(app: &mut EffectcraftApp) {
let n = app.session.project.comps().count() + 1;
let last = app.dialog_state.last_new_comp.clone().unwrap_or_default();
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);
}
pub fn open_comp_settings(app: &mut EffectcraftApp) -> Result<(), String> {
let cid = app.session.active_comp_id().ok_or("no composition is open")?;
open_comp_settings_for(app, cid)
}
/// Composition Settings on `cid`, open in the viewer or not.
pub fn open_comp_settings_for(app: &mut EffectcraftApp, cid: ItemId) -> Result<(), String> {
let c = app.session.project.comp(cid).ok_or("no composition")?;
app.dialog_state.comp = CompDraft {
name: app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(),
width: c.width,
height: c.height,
pixel_aspect: c.pixel_aspect,
fps: c.frame_rate.as_f64(),
duration: c.duration.seconds(),
start: c.display_start.seconds(),
bg: c.background,
shutter_angle: c.shutter_angle,
shutter_phase: c.shutter_phase,
samples: c.motion_blur_samples,
adaptive_limit: c.motion_blur_adaptive_limit,
preserve_frame_rate: c.preserve_frame_rate,
preserve_resolution: c.preserve_resolution,
advanced_3d: c.renderer == effectcraft_engine::project::Renderer::Advanced3D,
resolution: c.resolution,
..Default::default()
};
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(())
}
pub fn open_new_solid(app: &mut EffectcraftApp) -> Result<(), String> {
let c = app.session.active_comp().ok_or("no composition is open")?;
let n = app.session.project.items.values().filter(|i| matches!(i.kind, effectcraft_engine::project::ItemKind::Solid(_))).count() + 1;
let d = &mut app.dialog_state;
d.solid_name = format!("Solid {n}");
d.solid_size = [c.width, c.height];
d.solid_pixel_aspect = c.pixel_aspect;
d.solid_layer = None;
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(())
}
/// Layer ▸ Layer Settings on a solid or adjustment layer: Solid Settings on its solid.
fn open_solid_settings(app: &mut EffectcraftApp, layer: u64, item: ItemId) -> Result<(), String> {
let s = &app.session;
let it = s.project.item(item).ok_or("the layer's solid is missing")?;
let ItemKind::Solid(so) = &it.kind else { return Err("not a solid".into()) };
let users = s.project.comps().flat_map(|(_, c)| &c.layers).filter(|l| l.source == LayerSource::Solid { item }).count();
let name = s.active_comp().and_then(|c| c.layer(LayerId(layer))).map(|l| l.name.clone()).unwrap_or_else(|| it.name.clone());
let d = &mut app.dialog_state;
(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(())
}
/// Layer ▸ Layer Settings without parameters: Solid Settings for solids and adjustment layers,
/// the layer's name for nulls (cameras and lights: `dialogs_3d::route`). Returns true when a
/// dialog opened.
pub fn route_layer_settings(app: &mut EffectcraftApp, id: &str, params: &Value) -> Result<bool, String> {
if id != "layer.settings" || !params.as_object().is_none_or(|m| m.keys().all(|k| k == "layer")) {
return Ok(false);
}
let s = &app.session;
let Some(l) =
params.get("layer").and_then(Value::as_u64).or_else(|| s.state.selected_layers.first().map(|l| l.0)).and_then(|l| s.active_comp()?.layer(LayerId(l)))
else {
return Ok(false);
};
let (lid, name) = (l.id.0, l.name.clone());
match l.source {
LayerSource::Solid { item } => open_solid_settings(app, lid, item).map(|_| true),
LayerSource::Null => {
form(app, "Null Settings", "layer.settings", json!({"layer": lid}), vec![Field::text("name", "Name", &name)]);
Ok(true)
}
_ => Ok(false),
}
}
/// The room a modal dialog may take: the window less a margin.
pub(crate) fn modal_space(ctx: &egui::Context) -> egui::Vec2 {
(ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0))
}
fn modal_pass_id() -> egui::Id {
egui::Id::unique("effectcraft-modal-pass")
}
/// Whether a modal dialog showed in this pass or the one before: egui's `Modal` blocks input
/// only in its own window, so floating panels in OS windows of their own are blocked by the
/// dock area while this holds. Dialogs draw after the dock, so the previous pass counts.
pub(crate) fn modal_open(ctx: &egui::Context) -> bool {
let shown = ctx.data(|d| d.get_temp::<u64>(modal_pass_id()));
shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT))
}
pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
modal_with_scroll(ctx, title, size, t, false, body);
}
pub(crate) fn modal_vertical(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) {
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, _| {
let scroll = if vertical_only { egui::ScrollArea::vertical() } else { egui::ScrollArea::both() };
scroll.max_height(max_height).auto_shrink([false, true]).show(ui, body);
});
}
/// [`modal`] without its scroll area, for a dialog that keeps its buttons in view and scrolls
/// 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));
let id = egui::Id::unique(("modal", title));
// Dragging the title row moves the dialog off what it edits. The offset is kept within the
// 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(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(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);
ui.set_min_height(size.y - 36.0);
// The id stays keyed on the untranslated source, so switching language cannot move it.
let head = ui.label(egui::RichText::new(tr(title)).font(Tokens::semibold(15.0)).color(t.tab_text_active));
let row = Rect::from_x_y_ranges(ui.max_rect().x_range(), head.rect.y_range());
let grip = ui.interact(row, id.with("grip"), Sense::DRAG).on_hover_cursor(egui::CursorIcon::Grab);
if grip.dragged() {
let moved = (offset + grip.drag_delta()).clamp(-room, room);
ui.ctx().data_mut(|d| d.insert_temp(offset_id, moved));
}
ui.add_space(10.0);
let title_h = ui.cursor().top() - ui.min_rect().top();
body(ui, (size.y - 36.0 - title_h).max(40.0));
// A colour picker's eyedropper samples the viewer under the dialog, but not the dialog itself.
crate::color_picker::note_overlay(ui.ctx(), ui.min_rect().expand(18.0));
});
}
/// The buttons of a [`modal_fit`] body of `height`, kept in view: `footer` lays them out first,
/// at the bottom (bottom up: its first row is the lowest), and the rest of the body gets the room
/// above them. Returns that room (a left-to-right row, clipped to it, so a long or wide body
/// scrolls or clips under the buttons instead of pushing them out of the dialog) and its height.
pub(crate) fn above_footer(ui: &mut egui::Ui, height: f32, footer: impl FnOnce(&mut egui::Ui)) -> (egui::Ui, f32) {
let area = ui.allocate_space(vec2(ui.available_width(), height)).1;
let mut foot = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::bottom_up(egui::Align::Min)));
footer(&mut foot);
foot.add_space(8.0);
let body_h = (foot.min_rect().top() - area.top()).max(0.0);
let room = Rect::from_min_size(area.min, vec2(area.width(), body_h));
let mut body = ui.new_child(egui::UiBuilder::new().max_rect(room).layout(egui::Layout::left_to_right(egui::Align::Min)));
body.set_clip_rect(room.intersect(body.clip_rect()));
(body, body_h)
}
pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context) {
let Some(d) = app.dialog else { return };
let t = app.tokens;
match d {
Dialog::About => about(app, ctx, &t),
Dialog::NewComp | Dialog::CompSettings => comp_settings(app, ctx, &t, d == Dialog::CompSettings),
Dialog::SolidSettings => solid(app, ctx, &t),
Dialog::CommandPalette => palette(app, ctx, &t),
Dialog::Settings => super::settings::show(app, ctx, &t),
Dialog::Shortcuts => super::shortcut_editor::show(app, ctx, &t),
Dialog::Recovery => super::settings::recovery(app, ctx, &t),
Dialog::Form => super::forms::show_form(app, ctx, &t),
Dialog::Info => super::forms::show_info(app, ctx, &t),
Dialog::ViewOptions => super::forms::show_view_options(app, ctx, &t),
Dialog::CameraSettings => super::dialogs_3d::camera(app, ctx, &t),
Dialog::LightSettings => super::dialogs_3d::light(app, ctx, &t),
Dialog::NewPrimitive => super::primitive_dialog::dialog(app, ctx, &t),
Dialog::KeyVelocity => super::key_dialogs::velocity(app, ctx, &t),
Dialog::KeyInterpolation => super::key_dialogs::interpolation(app, ctx, &t),
Dialog::TimeStretch => super::key_dialogs::time_stretch(app, ctx, &t),
Dialog::TrackOptions => super::tracker::options_dialog(app, ctx, &t),
Dialog::Marker => super::markers_ui::dialog(app, ctx, &t),
Dialog::Precompose => super::precomp::dialog(app, ctx, &t),
Dialog::LayerStyles => super::layer_styles_dialog::show(app, ctx, &t),
Dialog::TrackTarget => super::tracker::target_dialog(app, ctx, &t),
Dialog::TrackApply => super::tracker::apply_dialog(app, ctx, &t),
Dialog::RenderTemplates => super::rq_templates::show(app, ctx, &t),
Dialog::UnsavedChanges => super::unsaved::show(app, ctx, &t),
Dialog::DeleteItems => super::delete_items::show(app, ctx, &t),
Dialog::AepLosses => super::aep_losses::show(app, ctx, &t),
Dialog::Unprecompose => super::unprecompose::show(app, ctx, &t),
Dialog::GradientEditor => super::gradient_editor::show(app, ctx, &t),
Dialog::DeleteTemplate => super::templates::show_delete(app, ctx, &t),
}
}
/// About tabs: About · Contributors · Models.
const ABOUT_TABS: [(&str, &str); 3] = [("About", "about"), ("Contributors", "contributors"), ("Models", "models")];
/// Height of the Contributors / Models tab bodies (they scroll inside it).
const CREDITS_HEIGHT: f32 = 380.0;
/// The About dialog: tabs for the name/version/blurb, the contributors and the models (the last
/// two scroll in a fixed-height body), the licence and bundled-font line, and Close. The tab is
/// kept in egui temp data. A command the About body asked for is run once the modal has been
/// built; Close clears `app.dialog`.
fn about(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut close = false;
let mut cmd: Option<&str> = None;
let tab_id = egui::Id::unique("about_tab");
// Wide enough for every Contributors column at its natural width (modals shrink to the window).
modal(ctx, "About EffectCraft", vec2(820.0, 520.0), t, |ui| {
let mut tab = ui.data_mut(|d| d.get_temp::<usize>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
for (i, (label, id)) in ABOUT_TABS.iter().enumerate() {
let r = ui.selectable_label(tab == i, *label);
app.auto.add(&format!("about.tab.{id}"), r.rect, label);
if r.clicked() {
tab = i;
}
}
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.separator();
match tab {
1 | 2 => {
let h = CREDITS_HEIGHT.min(ui.available_height().max(120.0));
ui.allocate_ui(vec2(ui.available_width(), h), |ui| {
ui.set_max_height(h);
if tab == 1 {
crate::credits::contributors_ui(ui, &mut app.auto);
} else {
crate::credits::models_ui(ui);
}
});
}
_ => about_main(app, ui, t, &mut cmd),
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(egui::RichText::new(tr("MIT OR Apache-2.0 • Fonts: Inter, JetBrains Mono (OFL)")).small().color(t.text_faint));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.button(tr("Close"));
app.auto.add("about.close", r.rect, "Close");
if r.clicked() {
close = true;
}
});
});
});
if let Some(c) = cmd {
let _ = app.session.execute(c, json!({}));
}
if close {
app.dialog = None;
}
}
/// The About tab: logo, version, blurb and community links.
fn about_main(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, cmd: &mut Option<&'static str>) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 96.0), Sense::hover());
let p = ui.painter();
p.rect_filled(r, 10.0, Color32::from_rgb(0x1b, 0x22, 0x3c));
crate::header::paint_logo(p, Rect::from_min_size(r.min + vec2(18.0, 20.0), vec2(56.0, 56.0)));
p.text(r.min + vec2(90.0, 34.0), Align2::LEFT_CENTER, "EffectCraft", Tokens::semibold(24.0), Color32::WHITE);
p.text(
r.min + vec2(90.0, 62.0),
Align2::LEFT_CENTER,
format!("Version {} • Motion graphics & VFX in pure Rust", env!("CARGO_PKG_VERSION")),
Tokens::ui(12.0),
t.text_dim,
);
ui.add_space(12.0);
ui.label(tr("A clean-room, open-source compositor for motion graphics and visual effects: native on macOS, Windows and Linux, and in the browser. Part of the ArtCraft family of creative apps."));
ui.add_space(14.0);
let links: [(Icon, &str, &str, &'static str); 4] = [
(Icon::Chat, "Join the ArtCraft Discord", effectcraft_engine::links::DISCORD, "help.discord"),
(Icon::Globe, "ArtCraft website", effectcraft_engine::links::WEBSITE, "help.website"),
(Icon::Sparkle, "EffectCraft home page", effectcraft_engine::links::APP_PAGE, "help.appPage"),
(Icon::Code, "Source code on GitHub", effectcraft_engine::links::GITHUB, "help.github"),
];
for (icon, label, url, c) in links {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
let p = ui.painter();
let discord = c == "help.discord";
p.rect_filled(
r,
6.0,
if discord {
Color32::from_rgb(0x58, 0x65, 0xf2)
} else if resp.hovered() {
t.hover
} else {
t.field_bg
},
);
let foreground = if discord { Color32::WHITE } else { t.text };
icons::paint(p, Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(15.0, 15.0)), icon, foreground);
p.text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), Tokens::medium(12.5), foreground);
p.text(
pos2(r.max.x - 12.0, r.center().y),
Align2::RIGHT_CENTER,
url,
Tokens::ui(11.0),
if discord { Color32::from_white_alpha(200) } else { t.text_dim },
);
app.auto.add(&format!("about.{c}"), r, label);
if resp.clicked() {
*cmd = Some(c);
}
ui.add_space(4.0);
}
}
fn comp_settings(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens, existing: bool) {
let mut d = app.dialog_state.comp.clone();
let mut result = None;
let custom = app.session.prefs.comp_presets.clone();
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.
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(super::comp_settings::dialog_width(ctx), 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;
modal(ctx, "Choose Name", vec2(360.0, 150.0), t, |ui| super::comp_settings::choose_name(ui, &mut d, t, &mut app.auto, &custom, &mut cmds));
}
// Enter that saved a preset name (or while one is typed) doesn't also press OK.
let preset_busy = !cmds.is_empty() || d.preset_name.is_some();
for (id, params) in cmds {
if let Err(e) = app.session.execute(id, params) {
app.ui.status = e.to_string();
}
}
if result.is_none() && !preset_busy && ui_enter(ctx) {
result = Some(super::comp_settings::params(&d));
}
app.dialog_state.comp = d;
match result {
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 {
Some(cid) if r.is_ok() => set_resolution(app, cid, app.dialog_state.comp.resolution).and(r),
_ => r,
};
if !existing && r.is_ok() {
app.dialog_state.last_new_comp = Some(app.dialog_state.comp.clone());
// The new composition opens: it leaves the Home screen, as `run_engine` does.
app.ui.start_screen = false;
}
if let Err(e) = r {
app.ui.status = e.to_string();
}
app.dialog = 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);
}
}
}
}
/// The Resolution chosen in Composition Settings, set as the Composition panel's popup sets it:
/// saved with the project but no undo step, so it stays out of `comp.new` / `comp.settings`.
fn set_resolution(app: &mut EffectcraftApp, cid: u64, r: effectcraft_engine::project::Resolution) -> effectcraft_engine::Result<Value> {
match r.factor() {
None => app.session.execute("view.res.auto", json!({"comp": cid})),
Some(n) => app.session.execute("view.res.custom", json!({"comp": cid, "factor": n})),
}
}
/// One frame of the open dialog with `key` pressed.
#[cfg(test)]
pub(crate) fn press_key(app: &mut EffectcraftApp, ctx: &egui::Context, key: egui::Key, modifiers: egui::Modifiers) {
let mut raw = egui::RawInput::default();
raw.events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers });
let mut out = ctx.run_ui(raw, |ui| {
app.auto.begin_frame();
show(app, ui.ctx());
});
out.textures_delta.clear();
}
/// The letter that answers the open confirmation with Cmd (Ctrl elsewhere): Cmd+D is Delete in
/// Delete Items, Delete Template and Delete Preset, Cmd+R is Revert in Revert Project. The native menu bar hands these keys back
/// while the prompt is open ([`crate::native_menu::activate`]).
pub(crate) fn command_key(app: &EffectcraftApp) -> Option<egui::Key> {
match app.dialog? {
Dialog::DeleteItems | Dialog::DeleteTemplate => Some(egui::Key::D),
Dialog::Shortcuts if super::shortcut_editor::confirming(app) => Some(egui::Key::D),
Dialog::UnsavedChanges if super::unsaved::is_revert(app) => Some(egui::Key::R),
_ => None,
}
}
/// Cmd+`key` (Ctrl elsewhere) pressed, also while a text field has focus: ask before the dialog
/// is drawn, so the field does not edit with the key first.
pub(crate) fn ui_command_key(ctx: &egui::Context, key: egui::Key) -> bool {
ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, key))
}
pub(crate) fn ui_enter(ctx: &egui::Context) -> bool {
!ctx.egui_wants_keyboard_input() && ctx.input(|i| i.key_pressed(egui::Key::Enter))
}
/// Solid Settings: a new solid (Layer ▸ New ▸ Solid…) or the layer's solid (Layer Settings).
fn solid(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let (mut close, mut ok) = (false, false);
let ds = &app.dialog_state;
let (mut name, mut color, mut size, mut par, mut affect_all) =
(ds.solid_name.clone(), ds.solid_color, ds.solid_size, ds.solid_pixel_aspect, ds.solid_affect_all);
let (editing, shared) = (ds.solid_layer, ds.solid_shared);
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_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));
crate::widgets::focus_on_open(ui.ctx(), &r, &name);
auto.add("dialog.solid.name", r.rect, "Name");
});
ui.add_space(8.0);
ui.label(egui::RichText::new(tr("Size")).strong());
egui::Grid::new("solid-grid").num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| {
let (ow, oh) = (size[0], size[1]);
ui.label(tr("Width:"));
ui.horizontal(|ui| {
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[0]).range(1..=30000).suffix(" px")));
auto.add("dialog.solid.width", r.rect, "Width");
let r = crate::widgets::labelled_checkbox(
ui,
&mut lock,
tr_args("Lock Aspect Ratio to {}", &[&(super::comp_settings::aspect_label(ow as f64, oh as f64))]),
);
auto.add("dialog.solid.lockAspect", r.rect, "Lock Aspect Ratio");
});
ui.end_row();
ui.label(tr("Height:"));
let r = ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut size[1]).range(1..=30000).suffix(" px")));
auto.add("dialog.solid.height", r.rect, "Height");
ui.end_row();
if lock && ow > 0 && oh > 0 {
if size[0] != ow {
size[1] = ((size[0] as f64) * oh as f64 / ow as f64).round().max(1.0) as u32;
} else if size[1] != oh {
size[0] = ((size[1] as f64) * ow as f64 / oh as f64).round().max(1.0) as u32;
}
}
ui.label(tr("Pixel Aspect Ratio:"));
let cur = super::comp_settings::PIXEL_ASPECTS
.iter()
.find(|(_, v)| (v - par).abs() < 1e-3)
.map(|p| p.0.to_string())
.unwrap_or_else(|| format!("{par:.2}"));
let r = craft_ui::ComboBox::from_id_salt("solid-par").width(240.0).selected_text(cur).show_ui(ui, |ui| {
for (label, v) in super::comp_settings::PIXEL_ASPECTS {
if ui.selectable_label((v - par).abs() < 1e-3, *label).clicked() {
par = *v;
}
}
});
auto.add("dialog.solid.pixelAspect", r.response.rect, "Pixel Aspect Ratio");
ui.end_row();
});
let pct = |a: u32, b: u32| 100.0 * a as f64 / b.max(1) as f64;
ui.label(
egui::RichText::new(format!(
"Width: {:.1}% of comp\nHeight: {:.1}% of comp\nFrame Aspect Ratio: {}",
pct(size[0], comp_size.0),
pct(size[1], comp_size.1),
super::comp_settings::aspect_label(size[0] as f64 * par, size[1] as f64)
))
.color(Color32::GRAY),
);
ui.add_space(4.0);
let r = ui.button(tr("Make Comp Size"));
auto.add("dialog.solid.makeCompSize", r.rect, "Make Comp Size");
if r.clicked() {
size = [comp_size.0, comp_size.1];
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label(tr("Color:"));
let r = crate::widgets::srgb_color_button(ui, &mut color);
auto.add("dialog.solid.color", r.rect, "Color");
});
if editing.is_some() {
ui.add_space(8.0);
let r = ui
.add_enabled(shared, crate::widgets::DragCheckbox::new(&mut affect_all, tr("Affect all layers that use this solid")))
.on_disabled_hover_text(tr("Only this layer uses this solid."));
auto.add("dialog.solid.affectAll", r.rect, "Affect all layers that use this solid");
if shared && !affect_all {
ui.label(egui::RichText::new(tr("This change will create a new solid for this layer.")).color(t.text_dim).size(11.0));
}
}
ui.add_space(14.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let r = ui.add(egui::Button::new(egui::RichText::new(tr(" OK ")).color(Color32::WHITE)).fill(t.accent));
auto.add("dialog.solid.ok", r.rect, "OK");
ok = r.clicked();
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) {
// 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;
}
}
/// The command Solid Settings' OK runs.
fn solid_command(d: &DialogState) -> &'static str {
if d.solid_layer.is_some() { "layer.settings" } else { "layer.newSolid" }
}
/// Its parameters.
fn solid_params(d: &DialogState) -> Value {
let mut p = json!({
"name": d.solid_name, "color": d.solid_color, "width": d.solid_size[0], "height": d.solid_size[1], "pixelAspect": d.solid_pixel_aspect,
});
if let Some(l) = d.solid_layer {
p["layer"] = json!(l);
p["affectAll"] = json!(d.solid_affect_all || !d.solid_shared);
}
p
}
/// ⌘⇧P: fuzzy search over every command (engine + UI) and effect.
fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
let mut q = app.dialog_state.palette_query.clone();
let mut run: Option<(String, serde_json::Value)> = None;
let mut close = false;
let items: Vec<(String, String, serde_json::Value, String)> = {
let mut v: Vec<(String, String, serde_json::Value, String)> = crate::menus::menu_items(app)
.into_iter()
.filter(|m| m.enabled)
.map(|m| (m.path.join(" ▸ ") + " ▸ " + &m.label, m.id, if m.params.is_null() { json!({}) } else { m.params }, m.shortcut.unwrap_or_default()))
.collect();
for c in effectcraft_engine::command_specs().iter().filter(|c| c.menu.is_empty() && c.journal && c.params == "{}") {
if app.session.is_enabled(c.id) {
v.push((c.label.to_string(), c.id.to_string(), json!({}), c.shortcut.unwrap_or("").to_string()));
}
}
v
};
let ql = q.to_lowercase();
let matches: Vec<&(String, String, serde_json::Value, String)> = items
.iter()
.filter(|(label, id, ..)| {
let hay = format!("{} {}", label.to_lowercase(), id.to_lowercase());
ql.split_whitespace().all(|w| hay.contains(w))
})
.take(14)
.collect();
modal(ctx, "Command Palette", vec2(560.0, 470.0), t, |ui| {
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Type a command or effect…")).desired_width(f32::INFINITY).font(Tokens::ui(14.0)));
// A placeholder disappears once typing starts; keep the field named for assistive input.
ctx.accesskit_node_builder(r.id, |node| node.set_label(tr("Type a command or effect…")));
// Take the keyboard when the palette opens (and if anything else takes it away), but not
// while the field already has it: egui's `request_focus` interrupts the IME composition,
// and asking on every frame cancelled the pinyin being typed, so no Chinese could be
// entered.
if !ctx.memory(|m| m.has_focus(r.id)) {
r.request_focus();
}
ui.add_space(8.0);
let sel = app.dialog_state.palette_sel.min(matches.len().saturating_sub(1));
for (i, (label, id, params, sc)) in matches.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::click());
let p = ui.painter();
if i == sel || resp.hovered() {
p.rect_filled(r, 4.0, if i == sel { t.row_selected } else { t.hover });
}
p.text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, label, Tokens::ui(12.5), t.text);
if !sc.is_empty() {
p.text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, crate::menus::shortcut_text(sc), Tokens::ui(11.0), t.text_dim);
}
if resp.clicked() {
run = Some((id.clone(), (*params).clone()));
}
}
});
let (down, up, enter) = ctx.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter)));
if down {
app.dialog_state.palette_sel = (app.dialog_state.palette_sel + 1).min(matches.len().saturating_sub(1));
}
if up {
app.dialog_state.palette_sel = app.dialog_state.palette_sel.saturating_sub(1);
}
if enter && let Some((_, id, params, _)) = matches.get(app.dialog_state.palette_sel) {
run = Some((id.clone(), (*params).clone()));
}
if q != app.dialog_state.palette_query {
app.dialog_state.palette_sel = 0;
}
app.dialog_state.palette_query = q;
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
close = true;
}
if let Some((id, params)) = run {
app.dialog = None;
if let Err(e) = crate::menus::invoke(app, ctx, &id, params) {
app.ui.status = e;
}
return;
}
if close {
app.dialog = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modal_open_holds_from_a_shown_dialog_until_the_pass_after_it_closes() {
let (app, ctx) = app();
let t = app.tokens;
// Asked as the dock asks it: during the pass, before the dialogs draw.
let pass = |show: bool| {
let mut open = false;
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
open = modal_open(ui.ctx());
if show {
modal(ui.ctx(), "Test", vec2(200.0, 100.0), &t, |_| {});
}
});
output.textures_delta.clear();
open
};
assert!(!pass(false));
assert!(!pass(true), "the first pass of a dialog finds none shown yet");
assert!(pass(true));
assert!(pass(false), "the pass after the last one shown still counts");
assert!(!pass(false));
}
fn app() -> (EffectcraftApp, egui::Context) {
let ctx = egui::Context::default();
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
crate::theme::install(&ctx, &app.tokens, crate::i18n::language(&app));
app.session.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360})).unwrap();
(app, ctx)
}
fn frame(app: &mut EffectcraftApp, ctx: &egui::Context) {
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
show(app, ui.ctx());
});
out.textures_delta.clear();
}
/// One frame with `events` in its input; returns what it asked the platform to do.
fn frame_with(app: &mut EffectcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> egui::FullOutput {
let input = egui::RawInput { events, ..Default::default() };
let mut out = ctx.run_ui(input, |ui| {
app.auto.begin_frame();
show(app, ui.ctx());
});
out.textures_delta.clear();
out
}
/// The IME composing `text` so far (what the platform sends as the user types pinyin).
fn composing(text: &str) -> egui::Event {
egui::Event::Ime(egui::ImeEvent::Preedit { text: text.to_string(), active_range_chars: None })
}
/// Whether the frame asked the platform to cancel the composition in progress. winit does
/// that whenever egui's focus is requested (`Memory::request_focus` interrupts the IME), and
/// on Windows the IME then drops what is being typed.
fn cancels_composition(out: &egui::FullOutput) -> bool {
out.platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition)
}
/// The palette's search field used to take the keyboard on every frame it was drawn, and
/// asking egui for the focus interrupts the IME composition: the pinyin being typed was
/// cancelled as it was typed, so no Chinese could be entered.
#[test]
fn the_palette_leaves_a_pinyin_composition_alone() {
let (mut app, ctx) = app();
crate::menus::invoke(&mut app, &ctx, "app.commandPalette", json!({})).unwrap();
assert_eq!(app.dialog, Some(Dialog::CommandPalette));
// The field takes the keyboard when the palette opens.
frame(&mut app, &ctx);
// Typing pinyin: the composition goes on and nothing cancels it.
let mut typed = String::new();
for k in "nihao".chars() {
typed.push(k);
let out = frame_with(&mut app, &ctx, vec![composing(&typed)]);
assert!(out.platform_output.ime.is_some(), "the field owns the IME");
assert!(!cancels_composition(&out), "typing `{typed}` cancelled the composition");
}
// The whole composition is in the field (and in the query the next frame filters with).
assert_eq!(app.dialog_state.palette_query, "nihao");
// Something else taking the keyboard takes it away for a frame; the field claims it back
// (a request is what gets the keys back, and there is no composition to interrupt then).
ctx.memory_mut(|m| m.request_focus(egui::Id::unique("elsewhere")));
assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_none(), "the field lost the keyboard");
assert!(frame_with(&mut app, &ctx, vec![]).platform_output.ime.is_some(), "and takes it back");
}
fn solid_item(app: &EffectcraftApp, l: u64) -> ItemId {
match app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().source {
LayerSource::Solid { item } => item,
_ => panic!("not a solid"),
}
}
#[test]
fn layer_settings_opens_solid_settings_on_the_layers_solid() {
let (mut app, ctx) = app();
let a =
app.session.execute("layer.newSolid", json!({"name": "Red", "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({})).unwrap();
assert_eq!(app.dialog, Some(Dialog::SolidSettings));
let d = &app.dialog_state;
assert_eq!((d.solid_layer, d.solid_shared, d.solid_size, d.solid_name.as_str()), (Some(a), false, [200, 100], "Red"));
frame(&mut app, &ctx);
for id in ["name", "width", "lockAspect", "height", "pixelAspect", "makeCompSize", "color", "affectAll", "ok", "cancel"] {
assert!(app.auto.find(&format!("dialog.solid.{id}")).is_some(), "dialog.solid.{id}");
}
// Only this layer uses the solid: OK changes it in place.
app.dialog_state.solid_color = [0.0, 1.0, 0.0];
assert_eq!(solid_command(&app.dialog_state), "layer.settings");
app.session.execute(solid_command(&app.dialog_state), solid_params(&app.dialog_state)).unwrap();
let item = solid_item(&app, a);
assert!(matches!(&app.session.project.item(item).unwrap().kind, ItemKind::Solid(so) if so.color == [0.0, 1.0, 0.0]));
// Shared with a duplicate: "Affect all" starts off, and OK gives the layer its own solid.
let b = app.session.execute("edit.duplicate", json!({})).unwrap()[0].as_u64().unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({"layer": b})).unwrap();
assert!(app.dialog_state.solid_shared && !app.dialog_state.solid_affect_all);
app.dialog_state.solid_size = [64, 64];
app.session.execute(solid_command(&app.dialog_state), solid_params(&app.dialog_state)).unwrap();
assert_ne!(solid_item(&app, a), solid_item(&app, b));
assert!(matches!(&app.session.project.item(solid_item(&app, a)).unwrap().kind, ItemKind::Solid(so) if so.width == 200));
// A null asks for its name; New Solid makes a solid with the comp's pixel aspect.
app.session.execute("layer.newNull", json!({})).unwrap();
crate::menus::invoke(&mut app, &ctx, "layer.settings", json!({})).unwrap();
assert_eq!((app.dialog, app.dialog_state.form.command.as_str()), (Some(Dialog::Form), "layer.settings"));
open_new_solid(&mut app).unwrap();
assert_eq!((solid_command(&app.dialog_state), app.dialog_state.solid_pixel_aspect), ("layer.newSolid", 1.0));
}
#[test]
fn new_comp_from_selection_asks_when_several_items_are_selected() {
let (mut app, ctx) = app();
let a = app.session.execute("file.importSolid", json!({"name": "A", "width": 100, "height": 50})).unwrap();
let b = app.session.execute("file.importSolid", json!({"name": "B", "width": 300, "height": 50})).unwrap();
let ids: Vec<ItemId> = [a, b].iter().map(|r| ItemId(r["item"].as_u64().unwrap())).collect();
app.session.state.project_selection = vec![ids[0]];
// One item: made at once.
crate::menus::invoke(&mut app, &ctx, "file.newCompFromSelection", json!({})).unwrap();
assert!(app.dialog.is_none());
app.session.state.project_selection = ids.clone();
crate::menus::invoke(&mut app, &ctx, "file.newCompFromSelection", json!({})).unwrap();
assert_eq!((app.dialog, app.dialog_state.form.command.as_str()), (Some(Dialog::Form), "file.newCompFromSelection"));
frame(&mut app, &ctx);
for k in ["single", "dimensionsFrom", "duration", "addToRenderQueue", "sequence", "overlap", "overlapDuration", "transition"] {
assert!(app.auto.find(&format!("form.field.{k}")).is_some(), "{k}");
}
// Its parameters are ones the command accepts (checked as agents' calls are).
let comps = app.session.project.comps().count();
app.session.execute_checked("file.newCompFromSelection", app.dialog_state.form.params()).unwrap();
assert_eq!(app.session.project.comps().count(), comps + 1, "Single Composition is the default");
}
#[test]
fn a_dialog_opens_with_its_name_field_focused_and_selected() {
let (mut app, ctx) = app();
let selected = |ctx: &egui::Context| {
let id = ctx.memory(|m| m.focused())?;
egui::TextEdit::load_state(ctx, id)?.cursor.char_range().map(|r| r.as_sorted_char_range()).map(|r| r.start.0..r.end.0)
};
open_new_comp(&mut app);
let name = app.dialog_state.comp.name.clone();
frame(&mut app, &ctx);
frame(&mut app, &ctx);
assert_eq!(selected(&ctx), Some(0..name.chars().count()), "typing replaces “{name}”");
// Afterwards the focus is the user's: it is not taken back.
ctx.memory_mut(|m| m.stop_text_input());
frame(&mut app, &ctx);
assert!(ctx.memory(|m| m.focused()).is_none());
// Closed and opened again: focused again.
app.dialog = None;
frame(&mut app, &ctx);
frame(&mut app, &ctx);
open_new_solid(&mut app).unwrap();
frame(&mut app, &ctx);
frame(&mut app, &ctx);
assert_eq!(selected(&ctx), Some(0..app.dialog_state.solid_name.chars().count()));
}
/// One frame of Composition Settings (or New Composition) with Enter pressed: OK.
fn press_ok(app: &mut EffectcraftApp, ctx: &egui::Context, existing: bool) {
let t = app.tokens;
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
ui.input_mut(|i| {
i.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() })
});
comp_settings(app, ui.ctx(), &t, existing);
});
out.textures_delta.clear();
assert!(app.dialog.is_none(), "OK closed the dialog");
}
/// The Resolution menu sets the comp's Resolution on OK. As from the Composition panel's popup
/// it is no undo step: the settings are one step, and undoing them keeps the Resolution.
#[test]
fn composition_settings_resolution_applies_on_ok_outside_the_undo_history() {
use effectcraft_engine::project::Resolution;
let (mut app, ctx) = app();
let cid = app.session.active_comp_id().unwrap();
app.session.execute("view.res.third", json!({})).unwrap();
open_comp_settings(&mut app).unwrap();
assert_eq!(app.dialog_state.comp.resolution, Resolution::Third, "the dialog opens on the comp's Resolution");
frame(&mut app, &ctx);
assert!(app.auto.find("dialog.comp.resolution").is_some());
let steps = app.session.history.undo.len();
(app.dialog_state.comp.resolution, app.dialog_state.comp.width) = (Resolution::Half, 700);
app.dialog_state.comp.lock_aspect = false;
press_ok(&mut app, &ctx, true);
let c = app.session.project.comp(cid).unwrap();
assert_eq!((c.width, c.resolution), (700, Resolution::Half));
assert_eq!(app.session.history.undo.len(), steps + 1, "Composition Settings is one undo step");
app.session.execute("edit.undo", json!({})).unwrap();
let c = app.session.project.comp(cid).unwrap();
assert_eq!((c.width, c.resolution), (640, Resolution::Half), "undo keeps the Resolution");
// Only the Resolution changed: still set, and no extra step.
open_comp_settings(&mut app).unwrap();
app.dialog_state.comp.resolution = Resolution::Custom(6);
let steps = app.session.history.undo.len();
press_ok(&mut app, &ctx, true);
assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6));
assert_eq!(app.session.history.undo.len(), steps, "no undo step");
// New Composition makes its comp at the menu's Resolution; the comp the viewer showed keeps its own.
open_new_comp(&mut app);
app.dialog_state.comp.resolution = Resolution::Quarter;
press_ok(&mut app, &ctx, false);
let new = app.session.active_comp_id().unwrap();
assert_ne!(new, cid);
assert_eq!(app.session.project.comp(new).unwrap().resolution, Resolution::Quarter);
assert_eq!(app.session.project.comp(cid).unwrap().resolution, Resolution::Custom(6));
}
#[test]
fn composition_settings_register_every_control_and_new_comp_remembers() {
let (mut app, ctx) = app();
open_new_comp(&mut app);
for tab in [super::super::comp_settings::Tab::Basic, super::super::comp_settings::Tab::Advanced, super::super::comp_settings::Tab::Renderer] {
app.dialog_state.comp.tab = tab;
frame(&mut app, &ctx);
}
let ids = [
"name",
"tab.basic",
"tab.advanced",
"tab.renderer",
"ok",
"cancel",
"renderer", // 3D Renderer tab (last shown)
];
for id in ids {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Advanced;
frame(&mut app, &ctx);
for id in ["anchor.4", "preserveFrameRate", "preserveResolution", "shutterAngle", "shutterPhase", "samples", "adaptiveLimit"] {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
app.dialog_state.comp.tab = super::super::comp_settings::Tab::Basic;
frame(&mut app, &ctx);
for id in ["preset", "width", "lockAspect", "height", "pixelAspect", "frameRate", "frameRates", "startTimecode", "duration", "background"] {
assert!(app.auto.find(&format!("dialog.comp.{id}")).is_some(), "dialog.comp.{id}");
}
// A typed rate that isn't in the list, then OK (Enter): the next New Composition starts
// from these settings with a new name.
(app.dialog_state.comp.width, app.dialog_state.comp.height, app.dialog_state.comp.fps) = (1000, 500, 18.0);
let p = super::super::comp_settings::params(&app.dialog_state.comp);
assert_eq!(p["frameRate"], json!(18.0));
let t = app.tokens;
let mut out = ctx.run_ui(Default::default(), |ui| {
app.auto.begin_frame();
ui.input_mut(|i| {
i.events.push(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() })
});
comp_settings(&mut app, ui.ctx(), &t, false);
});
out.textures_delta.clear();
assert!(app.dialog.is_none());
let c = app.session.active_comp().unwrap();
assert_eq!((c.width, c.height, c.frame_rate.as_f64()), (1000, 500, 18.0));
open_new_comp(&mut app);
let n = &app.dialog_state.comp;
assert_eq!((n.width, n.height, n.fps, n.name.as_str()), (1000, 500, 18.0, "Comp 3"));
}
}