refactor(ui-egui): move Effect Controls panel parts to panels/effect_controls/*.rs

This commit is contained in:
aiongg
2026-10-26 21:49:11 +00:00
parent 5c6375c7ea
commit e59048107a
7 changed files with 1708 additions and 1652 deletions
+5 -1
View File
@@ -5444,7 +5444,11 @@ mod tests {
("panels/gradient_editor.rs", include_str!("../panels/gradient_editor.rs")),
("panels/dialogs.rs", include_str!("../panels/dialogs.rs")),
("panels/dialogs_3d.rs", include_str!("../panels/dialogs_3d.rs")),
("panels/effect_controls.rs", include_str!("../panels/effect_controls.rs")),
("panels/effect_controls/common.rs", include_str!("../panels/effect_controls/common.rs")),
("panels/effect_controls/editors.rs", include_str!("../panels/effect_controls/editors.rs")),
("panels/effect_controls/panel.rs", include_str!("../panels/effect_controls/panel.rs")),
("panels/effect_controls/rows.rs", include_str!("../panels/effect_controls/rows.rs")),
("panels/effect_controls/viewer.rs", include_str!("../panels/effect_controls/viewer.rs")),
("panels/effects_presets.rs", include_str!("../panels/effects_presets.rs")),
("panels/essential.rs", include_str!("../panels/essential.rs")),
("panels/expr_bar.rs", include_str!("../panels/expr_bar.rs")),
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,109 @@
use super::*;
pub(super) type Actions = Vec<(String, serde_json::Value)>;
pub(super) const ROW: f32 = 20.0;
pub(super) const DIAL_ROW: f32 = 64.0;
// The effect title starts at 44 pt. Each level's names sit one tree step farther in;
// reserve the same icon gutter for properties and groups so sibling names align.
pub(super) const PARAM_NAME_X: f32 = 58.0;
pub(super) const TREE_INDENT: f32 = 14.0;
/// Selecting any descendant activates its owning effect without replacing the property
/// selection used by keyframe editing, copy and delete.
pub(super) fn effect_active(g: &PropGroup, layer: effectcraft_engine::project::LayerId, selected: &[(effectcraft_engine::project::LayerId, u64)]) -> bool {
fn contains(g: &PropGroup, uid: u64, depth: usize) -> bool {
if g.uid == uid {
return true;
}
if depth >= 16 {
return false;
}
g.children.iter().any(|n| n.uid() == uid || matches!(n, Node::Group(child) if contains(child, uid, depth + 1)))
}
selected.iter().any(|(l, uid)| *l == layer && contains(g, *uid, 0))
}
pub(super) fn selected_layer(app: &EffectcraftApp) -> Option<Layer> {
let comp = app.session.active_comp()?;
let id = app.session.state.selected_layers.first()?;
comp.layer(*id).cloned()
}
/// An effect was just applied (`effect.apply` / `effect.applyLast` with `params`): Effect
/// Controls comes up (opened if closed, brought to the front, the focus left where it is) on the
/// layer the effect went to, with the new effect selected, as in After Effects.
pub fn reveal_applied(app: &mut EffectcraftApp, params: &serde_json::Value) {
// `effect.apply` selects the new effect on (the last of) the layers it went to.
let target = app.session.state.selected_props.last().map(|(l, _)| *l);
if let Some(l) = target
&& !app.session.state.selected_layers.contains(&l)
&& params.get("comp").is_none()
&& app.session.active_comp().is_some_and(|c| c.layer(l).is_some())
{
// Show that layer (dropped on an unselected one), keeping the new effect selected.
let props = app.session.state.selected_props.clone();
match app.session.execute("layer.select", json!({"layers": [l.0]})) {
Ok(_) => app.session.state.selected_props = props,
Err(e) => app.ui.status = e.to_string(),
}
}
app.raise_panel(crate::dock::PanelKind::EffectControls);
}
/// After Effects asks for an effect's file as it applies it (Apply Color LUT opens the file
/// dialog for its LUT). For the instances `effect.apply` made (`applied`, its result), open the
/// file dialog for their first empty file parameter and set the file on each; cancelling
/// leaves the effect without one. Does nothing without a file dialog (tests, agents).
pub fn choose_files_after_apply(app: &mut EffectcraftApp, applied: &serde_json::Value) {
let Some(pick) = app.hooks.pick_files.as_ref() else { return };
let Some(comp) = app.session.active_comp() else { return };
let uids: Vec<u64> = applied.get("effects").and_then(|v| v.as_array()).map(|a| a.iter().filter_map(|v| v.as_u64()).collect()).unwrap_or_default();
let mut targets = vec![];
let mut filters: Vec<String> = vec![];
for uid in uids {
for l in &comp.layers {
let Some(g) = l.props.find_group(uid) else { continue };
let mut found = None;
g.walk("", &mut |_, pr| {
if found.is_none()
&& let ParamUi::File { filters } = &pr.ui
&& matches!(&pr.value, Value::Str(s) if s.trim().is_empty())
{
found = Some((pr.uid, filters.clone()));
}
});
if let Some((prop, f)) = found {
targets.push((l.id.0, prop));
filters = f;
}
}
}
if targets.is_empty() {
return;
}
let exts: Vec<&str> = filters.iter().map(String::as_str).collect();
let Some(path) = pick(&exts).into_iter().next() else { return };
for (layer, prop) in targets {
if let Err(e) = app.session.execute("prop.set", json!({"layer": layer, "prop": prop, "value": path})) {
app.ui.status = e.to_string();
}
}
}
/// An undo-merge key for one gesture on `id`: a new key each time a drag starts, so separate
/// drags are separate undo steps while one drag is one step.
pub(crate) fn gesture_key(ui: &egui::Ui, id: egui::Id, started: bool) -> String {
let kid = id.with("gesture");
let n: u64 = if started {
let n = ui.data(|d| d.get_temp::<u64>(kid)).unwrap_or(0) + 1;
ui.data_mut(|d| d.insert_temp(kid, n));
n
} else {
ui.data(|d| d.get_temp::<u64>(kid)).unwrap_or(0)
};
format!("{id:?}-{n}")
}
@@ -0,0 +1,380 @@
use super::common::{Actions, gesture_key};
use super::*;
/// Warp Stabilizer's Analyze / Cancel buttons and analysis status.
pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) {
let t = app.tokens;
let running = app.session.warp_target().is_some_and(|(_, l, u)| l == layer.id && u == g.uid) && app.session.is_warp_analyzing();
let busy = app.session.is_warp_analyzing();
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
if widgets::text_button(ui, b1, "Analyze", false, &t, egui::Id::unique(("warp-analyze", g.uid))).clicked() && !busy {
actions.push(("warp.analyze".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.warp.{}.analyze", g.uid), b1, "Analyze");
if running {
if widgets::text_button(ui, b2, "Cancel", false, &t, egui::Id::unique(("warp-cancel", g.uid))).clicked() {
actions.push(("warp.cancel".into(), json!({})));
}
app.auto.add(&format!("effectControls.warp.{}.cancel", g.uid), b2, "Cancel");
}
let analysed = matches!(g.get(effectcraft_engine::effects::warp_stab::ANALYSIS).map(|p| &p.value), Some(Value::Str(s)) if !s.is_empty());
let status = match app.session.warp_progress() {
Some(pr) if running => pr.banner(),
_ if analysed => "Analysis complete".to_string(),
_ if app.session.warp_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => "Waiting to analyze".to_string(),
_ => "Not analyzed: click Analyze".to_string(),
};
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
}
/// Roto Brush & Refine Edge: Freeze / Unfreeze and Propagate buttons and the segmentation status.
pub(super) fn roto_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) {
use effectcraft_engine::effects::roto as fx;
let t = app.tokens;
let params = effectcraft_engine::effects::flatten_params(g, &mut |pr| pr.value.clone());
let frozen = fx::is_frozen(&params);
let d = fx::data(&params);
let running = app.session.roto_target().is_some_and(|(_, l, u, _)| l == layer.id && u == g.uid) && app.session.is_roto_running();
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let label = if frozen { "Unfreeze" } else { "Freeze" };
if widgets::text_button(ui, b1, label, frozen, &t, egui::Id::unique(("roto-freeze-ec", g.uid))).clicked() && !running {
let id = if frozen { "roto.unfreeze" } else { "roto.freeze" };
actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.roto.{}.freeze", g.uid), b1, label);
let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Propagate", "roto.propagate") };
if widgets::text_button(ui, b2, l2, false, &t, egui::Id::unique(("roto-prop-ec", g.uid))).clicked() && !frozen {
actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.roto.{}.propagate", g.uid), b2, l2);
let status = match (app.session.roto_progress(), d.base) {
(Some(pr), _) if running => pr.banner(),
(_, None) => "No strokes: paint with the Roto Brush tool (Alt+W) in the Layer panel".to_string(),
(_, Some(b)) => format!("Base frame {b}, span {}–{}{}", d.span[0], d.span[1], if frozen { " (frozen)" } else { "" }),
};
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, status, Tokens::ui(11.5), t.text_dim);
}
/// Object Matte: Freeze / Unfreeze and Track / Stop buttons (the background job that computes
/// the matte over the tracked frames) and its status.
pub(super) fn object_matte_editor(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
g: &PropGroup,
r: Rect,
actions: &mut Actions,
) {
use effectcraft_engine::effects::object_matte as om;
let t = app.tokens;
let params = effectcraft_engine::effects::flatten_params(g, &mut |pr| pr.value.clone());
let frozen = om::is_frozen(&params);
let mine = app.session.roto_target().is_some_and(|(_, l, u, _)| l == layer.id && u == g.uid);
let running = mine && app.session.is_roto_running();
let busy = app.session.is_roto_running();
let b1 = Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 4.0), vec2(84.0, 22.0));
let b2 = Rect::from_min_size(pos2(b1.max.x + 8.0, b1.min.y), vec2(84.0, 22.0));
let label = if frozen { "Unfreeze" } else { "Freeze" };
if widgets::text_button(ui, b1, crate::i18n::tr(label), frozen, &t, egui::Id::unique(("om-freeze-ec", g.uid))).clicked() && !busy {
let id = if frozen { "objectMatte.unfreeze" } else { "objectMatte.freeze" };
actions.push((id.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.objectMatte.{}.freeze", g.uid), b1, label);
let (l2, id2) = if running { ("Stop", "roto.cancel") } else { ("Track", "objectMatte.propagate") };
if widgets::text_button(ui, b2, crate::i18n::tr(l2), false, &t, egui::Id::unique(("om-track-ec", g.uid))).clicked() && !frozen && (running || !busy) {
actions.push((id2.into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.objectMatte.{}.track", g.uid), b2, l2);
let base = om::reference_frame(&params);
let status = match app.session.roto_progress() {
Some(pr) if running => pr.banner(),
_ if frozen => format!("Frozen from reference frame {base}"),
_ if app.session.roto_pending.iter().any(|(_, l, u)| *l == layer.id && *u == g.uid) => "Waiting to track".to_string(),
_ => format!("Reference frame {base}: Track computes the matte ahead of playback"),
};
p.text(pos2(r.min.x + 24.0, r.min.y + 38.0), Align2::LEFT_CENTER, &status, Tokens::ui(11.5), t.text_dim);
app.auto.add(
&format!("effectControls.objectMatte.{}.status", g.uid),
Rect::from_min_size(pos2(r.min.x + 24.0, r.min.y + 30.0), vec2(r.width() - 24.0, 16.0)),
&status,
);
}
// ---------------------------------------------------------------------------------------------
// Curves / Levels editors
/// Height of the custom editor shown under an effect's header (0 when none).
pub(super) fn editor_height(effect: &str, g: &PropGroup, width: f32) -> f32 {
match effect {
crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => {
crate::panels::fx_editors::header_height(effect, g, width)
}
"ec.color.curves" => 34.0 + curves_size(width) + 26.0,
effectcraft_engine::effects::warp_stab::ID => 50.0,
effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::EDITOR_HEIGHT,
effectcraft_engine::effects::roto::ID | effectcraft_engine::effects::object_matte::ID => 50.0,
"ec.color.levels" | "ec.color.levelsic" => 34.0 + 80.0 + 58.0,
"ec.color.autolevels" | "ec.color.autocontrast" | "ec.color.autocolor" => 24.0 + 80.0 + 12.0,
_ => 0.0,
}
}
fn curves_size(width: f32) -> f32 {
(width - 72.0).clamp(120.0, 256.0)
}
/// Cached histogram of the displayed frame (recomputed when the frame changes).
fn frame_histogram(app: &mut EffectcraftApp, ctx: &egui::Context) -> Option<std::sync::Arc<[[u32; 256]; 5]>> {
let img = app.viewer_pixels()?;
let key = std::sync::Arc::as_ptr(&img) as usize;
let id = egui::Id::unique("ec-histogram");
if let Some((k, h)) = ctx.data(|d| d.get_temp::<(usize, std::sync::Arc<[[u32; 256]; 5]>)>(id))
&& k == key
{
return Some(h);
}
let h = std::sync::Arc::new(fw::histogram(&img));
ctx.data_mut(|d| d.insert_temp(id, (key, h.clone())));
Some(h)
}
fn channel_color(ch: usize) -> Color32 {
match ch {
1 => Color32::from_rgb(0xe0, 0x50, 0x50),
2 => Color32::from_rgb(0x50, 0xc8, 0x60),
3 => Color32::from_rgb(0x50, 0x80, 0xf0),
4 => Color32::from_gray(0x90),
_ => Color32::from_gray(0xb8),
}
}
/// A "Channel: [RGB ▾]" popup; returns the chosen channel.
#[allow(clippy::too_many_arguments)]
fn channel_popup(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
x: f32,
cy: f32,
cur: usize,
names: &[&str],
id: egui::Id,
auto: &str,
) -> Option<usize> {
let t = app.tokens;
p.text(pos2(x, cy), Align2::LEFT_CENTER, crate::i18n::tr("Channel:"), Tokens::ui(12.0), t.text_dim);
let dr = Rect::from_min_size(pos2(x + 58.0, cy - 9.0), vec2(80.0, 18.0));
let pop = id.with("pop");
if widgets::dropdown(ui, dr, names.get(cur).copied().unwrap_or(""), &t, id).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(auto, dr, "Channel");
let opts: Vec<String> = names.iter().map(|s| s.to_string()).collect();
widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(cur))
}
/// Curves: channel popup, Bezier/pencil mode, Reset, and the graph bound to the channel's
/// hidden point-list parameter.
#[allow(clippy::too_many_arguments)]
pub(super) fn curves_editor(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
g: &PropGroup,
ectx: &EvalCtx,
r: Rect,
actions: &mut Actions,
) {
let t = app.tokens;
let euid = g.uid;
// The channel lives in the effect's hidden Channel parameter (UI state for instances
// saved before it existed).
let chan_prop = g.get("channel");
let ch = match chan_prop {
Some(pr) => ectx.value(layer, pr).as_enum() as usize,
None => app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0),
}
.min(4);
let x0 = r.min.x + 30.0;
let cy = r.min.y + 17.0;
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
if let Some(n) = channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::unique(("ec-cch", euid)), &format!("effectControls.effect.{euid}.curves.channel"))
{
match chan_prop {
Some(pr) => actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": n}))),
None => {
app.ui.fx_curve_channel.insert(euid, n);
}
}
}
let pencil = app.ui.fx_curve_pencil.contains(&euid);
let mr = Rect::from_min_size(pos2(x0 + 146.0, cy - 9.0), vec2(56.0, 18.0));
if widgets::dropdown(ui, mr, if pencil { "Pencil" } else { "Bezier" }, &t, egui::Id::unique(("ec-cmode", euid))).clicked() {
if pencil {
app.ui.fx_curve_pencil.remove(&euid);
} else {
app.ui.fx_curve_pencil.insert(euid);
}
}
app.auto.add(&format!("effectControls.effect.{euid}.curves.mode"), mr, "Curve mode");
let Some(prop) = g.get(fw::CURVE_CHANNELS[ch].0) else { return };
let cur = match ectx.value(layer, prop) {
Value::Str(s) => fw::CurvePoints::parse(&s),
_ => fw::CurvePoints::identity(),
};
let rr = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 9.0), vec2(44.0, 18.0));
let rresp = ui.interact(rr, egui::Id::unique(("ec-creset", euid)), Sense::click());
p.text(rr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{euid}.curves.reset"), rr, "Reset curve");
if rresp.clicked() {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": prop.uid, "value": fw::CurvePoints::identity().format()})));
}
let size = curves_size(r.width());
let gr = Rect::from_min_size(pos2(x0 + 8.0, r.min.y + 38.0), vec2(size, size));
// Input/output gradients along the axes.
let col = channel_color(ch);
p.rect_filled(Rect::from_min_max(pos2(gr.min.x, gr.max.y + 3.0), pos2(gr.max.x, gr.max.y + 8.0)), 0.0, col.gamma_multiply(0.5));
let id = egui::Id::unique(("ec-curves", euid, ch));
let (resp, edit) = fw::curves_graph(ui, gr, &cur, ch, fw::CurveMode { pencil, natural: true }, id, &t);
app.auto.add(&format!("effectControls.effect.{euid}.curves.graph"), gr, "Curves");
let started = resp.drag_started() || resp.clicked();
if let Some(np) = edit.changed {
let key = gesture_key(ui, id, started);
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": prop.uid, "value": np.format(), "merge": key})));
}
p.text(
pos2(gr.min.x, gr.max.y + 18.0),
Align2::LEFT_CENTER,
if pencil { "Drag to draw the curve" } else { "Click adds a point; drag one off to remove" },
Tokens::ui(10.5),
t.text_faint,
);
}
/// Levels parameter ids for a channel: input black, input white, gamma, output black, output white.
fn levels_ids(effect: &str, ch: usize) -> [String; 5] {
if effect == "ec.color.levels" {
return effectcraft_engine::effects::levels_channel_ids(ch);
}
// Levels (Individual Controls) nests each channel's controls in its twirl-down group.
let pre = fw::LEVELS_CHANNELS[ch.min(4)].0;
["InBlack", "InWhite", "Gamma", "OutBlack", "OutWhite"].map(|s| format!("{pre}/{pre}{s}"))
}
/// Levels: (channel popup,) histogram, input black/gamma/white triangles, output bar with
/// black/white triangles.
#[allow(clippy::too_many_arguments)]
pub(super) fn levels_editor(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
g: &PropGroup,
effect: &str,
ectx: &EvalCtx,
r: Rect,
actions: &mut Actions,
) {
let t = app.tokens;
let euid = g.uid;
let x0 = r.min.x + 30.0;
let cy = r.min.y + 17.0;
// Levels keeps its channel in the Channel parameter; Levels (Individual Controls) shows
// every channel and the popup only picks the histogram.
let ch = if effect == "ec.color.levelsic" {
app.ui.fx_levels_channel.get(&euid).copied().unwrap_or(0).min(4)
} else {
g.get("channel").map(|pr| ectx.value(layer, pr).as_enum() as usize).unwrap_or(0).min(4)
};
if effect == "ec.color.levelsic" {
let names: [&str; 5] = [crate::i18n::tr("RGB"), crate::i18n::tr("Red"), crate::i18n::tr("Green"), crate::i18n::tr("Blue"), crate::i18n::tr("Alpha")];
if let Some(n) =
channel_popup(app, ui, p, x0, cy, ch, &names, egui::Id::unique(("ec-lch", euid)), &format!("effectControls.effect.{euid}.levels.channel"))
{
app.ui.fx_levels_channel.insert(euid, n);
}
} else {
p.text(pos2(x0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Histogram"), Tokens::ui(12.0), t.text_dim);
}
let w = (r.width() - 72.0).clamp(120.0, 256.0);
let hr = Rect::from_min_size(pos2(x0 + 8.0, r.min.y + 34.0), vec2(w, 80.0));
match frame_histogram(app, ui.ctx()) {
// Channel 0 (RGB) shows luminance; the others their own channel.
Some(h) => fw::draw_histogram(p, hr, &h[ch], channel_color(ch)),
None => {
p.rect_filled(hr, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c));
}
}
app.auto.add(&format!("effectControls.effect.{euid}.levels.histogram"), hr, "Histogram");
let ids = levels_ids(effect, ch);
let val = |id: &str| g.prop(id).map(|pr| ectx.value(layer, pr).as_f64()).unwrap_or(0.0);
let (ib, iw, gm, ob, ow) = (val(&ids[0]), val(&ids[1]), val(&ids[2]), val(&ids[3]), val(&ids[4]));
let mut set = |ui: &egui::Ui, id: &str, v: f64, started: bool| {
if let Some(pr) = g.prop(id) {
let key = gesture_key(ui, egui::Id::unique(("ec-lv", euid, id)), started);
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": v, "merge": key})));
}
};
let track = Rect::from_min_size(pos2(hr.min.x, hr.max.y + 1.0), vec2(w, 10.0));
let started = |ui: &egui::Ui| ui.input(|i| i.pointer.any_pressed());
let (r0, nv) = fw::level_handle(ui, track, ib, Color32::BLACK, egui::Id::unique(("ec-lib", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.inBlack"), r0, "Input Black");
if let Some(v) = nv {
set(ui, &ids[0], v.min(iw - 0.005), started(ui));
}
let gx = fw::gamma_handle(ib, iw, gm);
let (r1, nv) = fw::level_handle(ui, track, gx, Color32::from_gray(128), egui::Id::unique(("ec-lg", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.gamma"), r1, "Gamma");
if let Some(v) = nv {
set(ui, &ids[2], (fw::gamma_from_handle(ib, iw, v) * 100.0).round() / 100.0, started(ui));
}
let (r2, nv) = fw::level_handle(ui, track, iw, Color32::WHITE, egui::Id::unique(("ec-liw", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.inWhite"), r2, "Input White");
if let Some(v) = nv {
set(ui, &ids[1], v.max(ib + 0.005), started(ui));
}
// Output: a black→white bar with its two triangles.
let bar = Rect::from_min_size(pos2(hr.min.x, track.max.y + 10.0), vec2(w, 9.0));
let n = 32;
for k in 0..n {
let v = (k as f32 / (n - 1) as f32 * 255.0) as u8;
let x0 = bar.min.x + bar.width() * k as f32 / n as f32;
let x1 = bar.min.x + bar.width() * (k + 1) as f32 / n as f32;
p.rect_filled(Rect::from_min_max(pos2(x0, bar.min.y), pos2(x1, bar.max.y)), 0.0, Color32::from_gray(v));
}
let otrack = Rect::from_min_size(pos2(bar.min.x, bar.max.y + 1.0), vec2(w, 10.0));
let (r3, nv) = fw::level_handle(ui, otrack, ob, Color32::BLACK, egui::Id::unique(("ec-lob", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.outBlack"), r3, "Output Black");
if let Some(v) = nv {
set(ui, &ids[3], v, started(ui));
}
let (r4, nv) = fw::level_handle(ui, otrack, ow, Color32::WHITE, egui::Id::unique(("ec-low", euid, ch)), &t);
app.auto.add(&format!("effectControls.effect.{euid}.levels.outWhite"), r4, "Output White");
if let Some(v) = nv {
set(ui, &ids[4], v, started(ui));
}
}
/// Auto Levels / Auto Contrast / Auto Color: the histogram they work from.
pub(super) fn histogram_only(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: &PropGroup, r: Rect) {
let t = app.tokens;
let x0 = r.min.x + 30.0;
p.text(pos2(x0, r.min.y + 12.0), Align2::LEFT_CENTER, crate::i18n::tr("Histogram"), Tokens::ui(12.0), t.text_dim);
let w = (r.width() - 72.0).clamp(120.0, 256.0);
let hr = Rect::from_min_size(pos2(x0 + 8.0, r.min.y + 24.0), vec2(w, 80.0));
match frame_histogram(app, ui.ctx()) {
Some(h) => {
fw::draw_histogram(p, hr, &h[1], channel_color(1).gamma_multiply(0.6));
fw::draw_histogram_overlay(p, hr, &h[2], channel_color(2).gamma_multiply(0.6));
fw::draw_histogram_overlay(p, hr, &h[3], channel_color(3).gamma_multiply(0.6));
}
None => {
p.rect_filled(hr, 0.0, Color32::from_rgb(0x1c, 0x1c, 0x1c));
}
}
app.auto.add(&format!("effectControls.effect.{}.histogram", g.uid), hr, "Histogram");
}
@@ -0,0 +1,324 @@
use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, selected_layer};
use super::editors::{curves_editor, editor_height, histogram_only, levels_editor, object_matte_editor, roto_editor, warp_editor};
use super::rows::{FxScope, group_rows, toggle_closed};
use super::*;
// ---------------------------------------------------------------------------------------------
/// Open the rename field on effect `uid` (Enter with the effect selected), name selected.
pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) {
let id = egui::Id::unique(("ec-rename", uid));
ctx.data_mut(|d| d.insert_temp(id, name.to_string()));
ctx.data_mut(|d| d.insert_temp(id.with("select"), true));
ctx.memory_mut(|m| m.request_focus(id.with("field")));
}
/// The Effect Controls panel for the selected layer: a header with the comp and layer names, then
/// one header per effect (Fx toggle, twirl, rename, Reset, About, label bar, reorder drag and its
/// right-click menu) followed by that effect's custom editor and its property rows. Dragging
/// effects or presets onto the panel applies them; a right-click on empty space opens the Effect
/// menu. The rows read from a snapshot of the project, and every edit is queued and run through
/// [`crate::menus::invoke`], with failures reported in `app.ui.status`.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let ctx = ui.ctx().clone();
let Some(layer) = selected_layer(app) else {
p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a layer to see its effects"), Tokens::ui(12.0), t.text_faint);
return;
};
let Some(cid) = app.session.active_comp_id() else { return };
let comp = app.session.project.comp_arc(cid).unwrap_or_else(|| effectcraft_engine::project::Comp::new(1, 1, Default::default(), Default::default()).into());
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let snap_project = app.session.project.clone();
let snap_expr = app.session.expr.clone();
let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: app.session.time(), expr: snap_expr.as_deref(), footage: None };
let hdr = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
p.text(pos2(hdr.min.x + 10.0, hdr.center().y), Align2::LEFT_CENTER, format!("{} • {}", comp_name, layer.name), Tokens::ui(11.5), t.text_dim);
p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
let body = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), rect.max);
let scroll = widgets::PanelScroll::begin(ui, egui::Id::unique("ec-scroll"), body);
let mut y = body.min.y + 4.0 - scroll.offset;
let mut actions: Actions = vec![];
let bp = p.with_clip_rect(body);
// Rows scroll under the header: clip their widgets (and hit tests) to the body.
let panel_clip = ui.clip_rect();
ui.set_clip_rect(body.intersect(panel_clip));
let fx: Vec<PropGroup> = layer.effects().map(|f| f.groups().cloned().collect()).unwrap_or_default();
if fx.is_empty() {
bp.text(
pos2(body.center().x, body.min.y + 40.0),
Align2::CENTER_CENTER,
"No effects. Drag one from Effects & Presets.",
Tokens::ui(12.0),
t.text_faint,
);
}
// Clicking empty space deselects the effects.
let bg = ui.interact(body, egui::Id::unique("ec-bg"), Sense::click());
if bg.clicked() {
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && fx.iter().any(|g| effect_active(g, layer.id, &[(*l, *u)]))));
}
let has_clip = !app.session.state.effect_clipboard.is_empty();
let drag_id = egui::Id::unique("ec-drag-effect");
let dragging: Option<u64> = ctx.data(|d| d.get_temp(drag_id));
// Header tops, for drag-to-reorder.
let mut tops: Vec<(u64, f32)> = vec![];
for (fi, g) in fx.iter().enumerate() {
let GroupKind::Effect { effect } = &g.kind else { continue };
// The header draws the effect's name in the interface language; the automation labels
// below keep the property tree's name, which is the registry's English name (#821).
let shown = crate::i18n::effects::group(effect, &g.name);
let r = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), ROW + 2.0));
tops.push((g.uid, r.min.y));
y += ROW + 2.0;
let open = !app.ui.fx_closed.contains(&g.uid);
let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid);
let active = effect_active(g, layer.id, &app.session.state.selected_props);
bp.rect_filled(r, 0.0, if active { t.fx_header_active } else { t.fx_header });
// The effect's colour label (26.5): a bar on the header's left edge.
if let Some(lab) = g.label.filter(|l| *l != effectcraft_engine::color::Label::None) {
let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
bp.rect_filled(Rect::from_min_size(r.min, vec2(4.0, r.height())), 0.0, Color32::from_rgb(lr, lg, lb));
app.auto.add(
&format!("effectControls.effect.{}.label", g.uid),
Rect::from_min_size(r.min, vec2(4.0, r.height())),
&app.session.prefs.label_name(lab),
);
}
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::unique(("ec-fx", g.uid)), Sense::click()).clicked() {
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name);
let tw = Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(12.0, 12.0));
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-tw", g.uid)), &t).clicked() {
toggle_closed(app, g, open, ui.input(|i| i.modifiers.alt || i.modifiers.command));
}
app.auto.add(&format!("effectControls.effect.{}.twirl", g.uid), tw, &g.name);
let rename_id = egui::Id::unique(("ec-rename", g.uid));
let editing: Option<String> = ctx.data(|d| d.get_temp(rename_id));
let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0));
if let Some(mut value) = editing.as_ref().cloned() {
let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0));
let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0)));
// Focus the field the frame it first exists: focusing an id not yet on screen leaves
// the accessibility tree with a focus it doesn't contain.
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_id.with("start"))).is_some() {
crate::widgets::select_all(&ctx, response.id, &value);
response.request_focus();
}
app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name");
if ctx.data_mut(|d| d.remove_temp::<bool>(rename_id.with("select"))).unwrap_or(false) {
widgets::select_all(&ctx, response.id, &value);
}
// Escape cancels. egui drops the field's focus on Escape in the same frame, so the
// key is checked on the focus being lost as well as held.
let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape));
let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)));
if escape || commit {
ctx.data_mut(|d| d.remove::<String>(rename_id));
// The field opens with the drawn name, so committing it unchanged is not a rename.
if !escape && !value.trim().is_empty() && value != shown {
actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": g.uid, "name": value})));
}
} else {
ctx.data_mut(|d| d.insert_temp(rename_id, value));
}
} else {
let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::semibold(12.0), t.text);
app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name);
}
let rresp = ui.interact(reset, egui::Id::unique(("ec-reset", g.uid)), Sense::click());
bp.text(reset.center(), Align2::CENTER_CENTER, crate::i18n::tr("Reset"), Tokens::ui(11.5), if rresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.reset", g.uid), reset, "Reset");
if rresp.clicked() {
actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
// About…: the effect's name and category (AE's About dialog).
let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0));
let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click());
bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim });
app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About");
let apop = egui::Id::unique(("ec-about-pop", g.uid));
if aresp.clicked() {
widgets::open_popup(ui, apop);
}
if let Some(spec) = effectcraft_engine::effects::find(effect) {
let lines = vec![
crate::i18n::effects::name(spec.id, spec.name),
format!("Category: {}", crate::i18n::effects::category(spec.category)),
"EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(),
];
let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None);
}
let hresp = ui.interact(
Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)),
egui::Id::unique(("ec-hdr", g.uid)),
if editing.is_some() { Sense::hover() } else { Sense::click_and_drag() },
);
if hresp.double_clicked() {
ctx.data_mut(|d| d.insert_temp(rename_id, shown.clone()));
ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true));
}
hresp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, &shown));
app.auto.add(&format!("effectControls.effect.{}", g.uid), r, &g.name);
if hresp.clicked() || hresp.drag_started() || hresp.secondary_clicked() {
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
if add && hresp.clicked() {
if selected {
app.session.state.selected_props.retain(|(l, u)| !(*l == layer.id && *u == g.uid));
} else {
app.session.state.selected_props.push((layer.id, g.uid));
}
} else if !selected || hresp.clicked() {
app.session.state.selected_props = vec![(layer.id, g.uid)];
}
}
if hresp.drag_started() {
ctx.data_mut(|d| d.insert_temp(drag_id, g.uid));
}
crate::widgets::context_menu(&hresp, |ui| {
let lid = layer.id.0;
let mut picked_label: Option<usize> = None;
let mut item = |ui: &mut egui::Ui, label: &str, enabled: bool, cmd: &str, params: serde_json::Value| {
if ui.add_enabled(enabled, egui::Button::new(crate::i18n::context_item(cmd, &params, label))).clicked() {
actions.push((cmd.into(), params));
ui.close();
}
};
item(ui, "Copy", true, "effect.copy", json!({"layer": lid, "effect": g.uid}));
item(ui, "Paste", has_clip, "effect.paste", json!({"layers": [lid]}));
item(ui, "Duplicate", true, "effect.duplicate", json!({"layer": lid, "effect": g.uid}));
if ui.button(crate::i18n::tr("Rename")).clicked() {
ctx.data_mut(|d| d.insert_temp(rename_id, shown.clone()));
ctx.data_mut(|d| d.insert_temp(rename_id.with("start"), true));
ui.close();
}
ui.separator();
ui.menu_button(crate::i18n::tr("Label"), |ui| {
let cur = g.label.unwrap_or(effectcraft_engine::color::Label::None);
for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == cur, &t).clicked() {
picked_label = Some(i);
ui.close();
}
}
});
item(ui, "Reset", true, "effect.reset", json!({"layer": lid, "effect": g.uid}));
item(ui, "Remove", true, "effect.remove", json!({"layer": lid, "effect": g.uid}));
ui.separator();
item(ui, "Move Up", fi > 0, "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi.max(1)}));
item(ui, "Move Down", fi + 1 < fx.len(), "effect.reorder", json!({"layer": lid, "effect": g.uid, "index": fi + 2}));
if let Some(i) = picked_label {
actions.push(("effect.setLabel".into(), json!({"layer": lid, "effect": g.uid, "label": i})));
}
});
// What the effect can't render as asked (an OCIO config it can't read, transforms it
// passes through), under its name.
if let Some(w) = effectcraft_engine::effects::warning(g, &mut |pr| ectx.value(&layer, pr)) {
let galley = bp.layout(format!("⚠ {w}"), Tokens::ui(11.0), t.warning, (body.width() - 20.0).max(60.0));
let wr = Rect::from_min_size(pos2(body.min.x + 10.0, y + 3.0), galley.size());
bp.galley(wr.min, galley, t.warning);
app.auto.add(&format!("effectControls.effect.{}.warning", g.uid), wr, &w);
y += wr.height() + 6.0;
}
if open {
let eh = editor_height(effect, g, body.width());
if eh > 0.0 {
let er = Rect::from_min_size(pos2(body.min.x, y), vec2(body.width(), eh));
y += eh;
if er.max.y >= body.min.y && er.min.y <= body.max.y {
// Curves, Levels and other custom editors do not use prop_row.
// Clicking their controls must activate the owner as well.
if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(er) {
actions.push(("prop.select".into(), json!({"layer":layer.id.0,"prop":g.uid})));
}
match effect.as_str() {
"ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions),
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions),
effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions),
effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions),
effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, &bp, &layer, g, er, &mut actions),
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions),
crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => {
crate::panels::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions)
}
_ => histogram_only(app, ui, &bp, g, er),
}
}
}
let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() };
group_rows(app, ui, &bp, &layer, g, &ectx, body, &mut y, 0, &scope, &mut actions);
if effect == "ec.color.tint" {
let r = Rect::from_min_size(pos2(body.min.x + PARAM_NAME_X, y + 2.0), vec2(120.0, ROW));
let label = crate::i18n::tr("Swap Colors");
let resp = widgets::text_button(ui, r, label, false, &t, egui::Id::unique(("ec-tint-swap", g.uid)));
app.auto.add_response(&format!("effectControls.effect.{}.swapColors", g.uid), &resp, label);
if resp.clicked() {
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": g.uid})));
actions.push(("effect.swapTintColors".into(), json!({"layer": layer.id.0, "effect": g.uid})));
}
y += ROW + 4.0;
}
}
y += 4.0;
}
// Drag an effect header to reorder: insertion line between headers.
if let Some(du) = dragging {
if let Some(pos) = ctx.pointer_interact_pos() {
let slot = tops.iter().position(|(_, ty)| pos.y < *ty + (ROW + 2.0) / 2.0).unwrap_or(tops.len());
let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0);
bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent));
if ctx.input(|i| i.pointer.any_released()) {
if let Some(from) = tops.iter().position(|(u, _)| *u == du) {
let to = if slot > from { slot - 1 } else { slot };
if to != from {
actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": du, "index": to + 1})));
}
}
ctx.data_mut(|d| d.remove::<u64>(drag_id));
}
}
if !ctx.input(|i| i.pointer.any_down()) {
ctx.data_mut(|d| d.remove::<u64>(drag_id));
}
}
// Right-click anywhere a control has no menu of its own: the Effect menu, which applies to
// the selected layers (After Effects). Drawn after the rows, whose menus open first.
let menu_id = bg.id.with("effect-menu");
let open = ui.input(|i| i.pointer.secondary_clicked())
&& ui.rect_contains_pointer(body)
&& (!egui::Popup::is_any_open(&ctx) || egui::Popup::is_id_open(&ctx, menu_id));
egui::Popup::context_menu(&bg).style(crate::widgets::menu_style).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| {
ui.set_min_width(200.0);
actions.extend(crate::menus::menu_contents(app, ui, "Effect"));
});
ui.set_clip_rect(panel_clip);
let content_h = y + scroll.offset - body.min.y;
scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t);
// Drop effects and animation presets here.
if let Some(payload) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(&ctx)
&& ui.rect_contains_pointer(rect)
&& let Some((id, params)) = match payload.as_ref() {
crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))),
crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)),
_ => None,
}
{
p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
if ctx.input(|i| i.pointer.any_released()) {
actions.push((id.into(), params));
egui::DragAndDrop::clear_payload(&ctx);
}
}
if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
app.ui.viewer.property_interacting = true;
ctx.request_repaint();
}
for (id, params) in actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
}
}
}
@@ -0,0 +1,626 @@
use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key};
use super::*;
fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) {
let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0);
p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE));
}
/// ◀ ◆ ▶ at the right end of an animated parameter's row.
fn key_navigator(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
prop: &Property,
ectx: &EvalCtx,
r: Rect,
actions: &mut Actions,
) {
let t = app.tokens;
let cy = r.center().y;
let uid = prop.uid;
let half = ectx.comp.frame_duration().0 / 2;
let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half);
for (what, dx) in [("prev", -44.0), ("key", -32.0), ("next", -20.0)] {
let cr = Rect::from_center_size(pos2(r.max.x + dx, cy), vec2(12.0, 16.0));
let resp = ui.interact(cr, egui::Id::unique(("ec-nav", uid, what)), Sense::click());
let col = if resp.hovered() { t.text } else { t.text_dim };
match what {
"prev" => triangle(p, cr.center(), true, col),
"next" => triangle(p, cr.center(), false, col),
_ => {
let kr = Rect::from_center_size(cr.center(), vec2(10.0, 10.0));
if on_key {
icons::paint(p, kr, Icon::Keyframe, t.keyframe);
} else {
let c = kr.center();
let d = 4.5;
p.add(egui::Shape::closed_line(
vec![pos2(c.x, c.y - d), pos2(c.x + d, c.y), pos2(c.x, c.y + d), pos2(c.x - d, c.y)],
Stroke::new(1.0, col),
));
}
}
}
app.auto.add(&format!("effectControls.prop.{uid}.{what}Key"), cr, &prop.name);
if resp.clicked() {
match what {
"prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))),
"next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))),
_ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))),
}
}
}
}
/// Toggle a viewer pick (crosshair / eyedropper) for a parameter (also the Timeline's colour
/// eyedroppers).
pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, prop: &Property) {
let pick = FxPick { kind: kind.into(), layer: layer.id.0, prop: prop.uid, name: prop.name.clone() };
if app.ui.fx_pick.as_ref() == Some(&pick) {
app.ui.fx_pick = None;
} else {
app.ui.fx_pick = Some(pick);
app.ui.text_color_pick = None;
app.ui.status = if kind == "point" {
format!("Click in the Composition panel to set {}", prop.name)
} else {
format!("Click in the Composition panel to sample {}", prop.name)
};
}
}
/// One property row: stopwatch, name, value control (and the keyframe navigator). Returns the
/// value fields of a vector, one per dimension, for the pick whip.
#[allow(clippy::too_many_arguments)]
fn prop_row(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
group: &PropGroup,
prop: &Property,
ectx: &EvalCtx,
r: Rect,
indent: f32,
actions: &mut Actions,
) -> Vec<Rect> {
let t = app.tokens;
let mut dims = vec![];
let cy = r.center().y;
let uid = prop.uid;
if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(r) {
let add = ui.input(|i| i.modifiers.shift || i.modifiers.command);
actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": add})));
}
let swr = Rect::from_center_size(pos2(r.min.x + indent, cy), vec2(14.0, 14.0));
if !prop.static_only && !matches!(prop.ui, ParamUi::Hidden) {
let resp = ui.interact(swr, egui::Id::unique(("ec-sw", uid)), Sense::click());
icons::paint(
p,
swr,
Icon::Stopwatch,
if prop.is_animated() {
t.hot_text
} else if resp.hovered() {
t.text
} else {
t.text_dim
},
);
app.auto.add(&format!("effectControls.prop.{uid}.stopwatch"), swr, &prop.name);
if resp.clicked() {
actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true})));
}
}
if prop.is_animated() {
key_navigator(app, ui, p, layer, prop, ectx, r, actions);
}
let value = ectx.value(layer, prop);
// Keep a shared value column while names and their icons follow the group hierarchy.
// Extremely deep groups still leave a little name space before their value.
let vx = (r.min.x + r.width() * 0.48).max(r.min.x + PARAM_NAME_X + 124.0).max(swr.max.x + 40.0);
// A vector's values end before the keyframe navigator: in a narrow panel they move left over
// the name (cut short) instead of running under the navigator (#271).
let vx = match &value {
Value::Vec2(_) | Value::Vec3(_) => {
let point = if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { 22.0 } else { 0.0 };
let fields: f32 = value
.components()
.iter()
.take(shown_dims(layer, prop, &value))
.map(|v| p.layout_no_wrap(format!("{v:.1}"), Tokens::ui(12.0), t.hot_text).size().x + 12.0)
.sum();
let right = r.max.x - if prop.is_animated() { 52.0 } else { 8.0 };
vx.min(right - point - fields + 8.0).max(swr.max.x + 60.0)
}
_ => vx,
};
let name_clip = Rect::from_min_max(pos2(swr.max.x + 6.0, r.min.y), pos2(vx - 6.0, r.max.y));
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
p.with_clip_rect(name_clip.intersect(p.clip_rect())).text(pos2(swr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text);
let name_response = ui.interact(name_clip, egui::Id::unique(("ec-prop-name", uid)), Sense::click());
if name_response.double_clicked() {
app.ui.timeline.search.clear();
app.ui.timeline.scroll_to = Some(crate::state::ScrollTo::Property(uid));
if let Err(e) = crate::menus::frontend(app, ui.ctx(), "timeline.revealProps", json!({"props": [{"layer": layer.id.0, "prop": uid}]})) {
app.ui.status = e;
}
app.show_panel(crate::dock::PanelKind::Timeline);
}
app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name);
let merge = format!("ec-{uid}");
// With several layers selected the edit applies to the same effect property of each (#491).
let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
let set = |actions: &mut Actions, v: serde_json::Value| {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
};
let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| {
let open = app.ui.fx_slider_open.contains(&uid);
let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0));
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), &t).clicked() {
if open {
app.ui.fx_slider_open.remove(&uid);
} else {
app.ui.fx_slider_open.insert(uid);
}
}
app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name);
};
match &value {
Value::Scalar(v) if matches!(prop.ui, ParamUi::Mask) => {
// A mask of this layer ("Path" popup).
let mut opts = vec!["None".to_string()];
if let Some(m) = layer.masks() {
opts.extend(m.groups().map(|g| g.name.clone()));
}
let i = v.round().max(0.0) as usize;
let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}"));
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
let pop = egui::Id::unique(("ec-mpop", uid));
if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) {
set(actions, json!(ni as f64));
}
}
Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => {
let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t);
app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1));
app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name);
if let Some(nv) = nv {
set(actions, json!(nv));
}
twirl_open(app, ui);
}
Value::Scalar(v) => {
let (min, max, dec) = match &prop.ui {
ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize),
_ => (-1e9, 1e9, 1),
};
let suffix = match prop.ui {
ParamUi::Percent => "%",
_ => "",
};
let speed = widgets::slider_scrub_speed(&prop.ui);
let (vr, nv, _) = widgets::hot_number_at(
ui,
pos2(vx, cy - 9.0),
egui::Id::unique(("ec-v", uid)),
*v,
speed,
(min, max),
dec.max(if dec == 0 { 0 } else { 1 }),
suffix,
&t,
);
app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name);
if let Some(nv) = nv {
set(actions, json!(nv));
}
// AE hides a slider param's slider until its twirl is opened (see `slider_row`).
if matches!(prop.ui, ParamUi::Slider { .. }) {
twirl_open(app, ui);
}
}
Value::Vec2(_) | Value::Vec3(_) => {
let c = value.components();
let n = shown_dims(layer, prop, &value);
let mut x = vx;
if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) {
let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point");
if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t)
.on_hover_text(crate::i18n::tr("Click, then click in the Composition panel"))
.clicked()
{
toggle_pick(app, "point", layer, prop);
}
app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name);
x += 22.0;
}
for d in 0..n.min(c.len()) {
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t);
app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name);
dims.push(vr);
if let Some(nv) = nv {
let mut nc = c.clone();
nc[d] = nv;
set(actions, json!(nc));
// Typed into this component only: other selected layers keep the rest of
// their own value (#1434).
if let Some((_, a)) = actions.last_mut() {
a["dims"] = json!([d]);
}
}
x = vr.max.x + 8.0;
}
}
Value::Color(c) => {
let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0));
let pop = egui::Id::unique(("ec-cpop", uid));
if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name);
let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0));
let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t)
.on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
.clicked()
{
toggle_pick(app, "color", layer, prop);
}
app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name);
let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) {
set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
}
}
Value::Bool(b) => {
let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0));
if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() {
set(actions, json!(!b));
}
p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim);
app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name);
}
Value::Enum(i) => {
if let ParamUi::Popup { options } = &prop.ui {
let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max);
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0));
let pop = egui::Id::unique(("ec-epop", uid));
if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) {
set(actions, json!(ni));
}
}
}
Value::Layer(l) => {
let comp = app.session.active_comp_arc();
if let Some(comp) = comp {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0));
if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) {
set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name);
// Source / Masks / Effects & Masks (the hidden companion parameter).
if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) {
let si = src.value.as_enum() as usize;
let opts: Vec<String> = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect();
let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0));
let spop = egui::Id::unique(("ec-lspop", uid));
if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() {
widgets::open_popup(ui, spop);
}
app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name);
if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni})));
}
}
}
}
// EXtractoR's red / green / blue / alpha: a popup of the footage's OpenEXR channels (#295).
Value::Str(s) if crate::panels::fx_editors::is_extractor_channel(group, prop) => {
let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0));
crate::panels::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions);
}
// A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…,
// as in After Effects.
Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => {
let bw = 70.0;
let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0));
if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() {
let filters: Vec<&str> = match &prop.ui {
ParamUi::File { filters } => filters.iter().map(String::as_str).collect(),
_ => vec![],
};
match app.hooks.pick_files.as_ref() {
Some(pick) => {
if let Some(path) = pick(&filters).into_iter().next() {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path})));
}
}
None => app.ui.status = "no file dialog available: set the path with prop.set".into(),
}
}
app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name);
let shown = if s.trim().is_empty() {
crate::i18n::tr("None").to_string()
} else if s.contains('\n') {
prop.name.clone()
} else {
effectcraft_engine::sequence::file_name(s)
};
let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y));
let np = p.with_clip_rect(nr.intersect(p.clip_rect()));
np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text });
let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover());
if !s.trim().is_empty() && !s.contains('\n') {
hover.on_hover_text(s.as_str());
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown);
}
// A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names):
// typed in a field, set when it loses the focus (Esc keeps the old text).
Value::Str(s) if matches!(prop.ui, ParamUi::Text) => {
let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0));
let bid = egui::Id::unique(("ec-text", uid));
let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone());
let resp = widgets::text_field(ui, fr, &mut buf, "", &t);
// The field has no visible hint: give the screen reader the parameter's name.
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name));
app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name);
if resp.lost_focus() {
if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf})));
}
ui.data_mut(|d| d.remove::<String>(bid));
} else if resp.has_focus() {
ui.data_mut(|d| d.insert_temp(bid, buf));
}
}
Value::Gradient(g) => {
let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0));
let n = 48;
for k in 0..n {
let f = k as f64 / (n - 1) as f64;
let c = gradient_at(g, f);
let x0 = gr.min.x + gr.width() * k as f32 / n as f32;
let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32;
p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c);
}
p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name);
}
_ => {}
}
dims
}
/// How many of a vector parameter's values show (a 3D point's Z only on a 3D layer).
fn shown_dims(layer: &Layer, prop: &Property, value: &Value) -> usize {
let n = value.components().len();
if prop.shown_dims > 0 && !(layer.is_3d() && n == 3) { prop.shown_dims as usize } else { n }
}
/// Colour of a gradient at `f` (colour stops only).
fn gradient_at(g: &effectcraft_engine::keyframe::Gradient, f: f64) -> Color32 {
let stops = &g.colors;
let c = if stops.is_empty() {
[0.0, 0.0, 0.0, 1.0]
} else if f <= stops[0].0 {
stops[0].1
} else if f >= stops[stops.len() - 1].0 {
stops[stops.len() - 1].1
} else {
let i = stops.iter().position(|s| s.0 > f).unwrap_or(stops.len() - 1).max(1);
let (a, b) = (stops[i - 1], stops[i]);
let k = ((f - a.0) / (b.0 - a.0).max(1e-9)) as f32;
[0, 1, 2, 3].map(|j| a.1[j] + (b.1[j] - a.1[j]) * k)
};
Color32::from_rgb((c[0].clamp(0.0, 1.0) * 255.0) as u8, (c[1].clamp(0.0, 1.0) * 255.0) as u8, (c[2].clamp(0.0, 1.0) * 255.0) as u8)
}
/// The twirled-open slider under a slider param: track, knob and the slider range's ends.
#[allow(clippy::too_many_arguments)]
fn slider_row(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
prop: &Property,
ectx: &EvalCtx,
r: Rect,
x0: f32,
actions: &mut Actions,
) {
let t = app.tokens;
let ParamUi::Slider { slider_min, slider_max, decimals, .. } = prop.ui else { return };
let Value::Scalar(v) = ectx.value(layer, prop) else { return };
let uid = prop.uid;
let tr = Rect::from_min_max(pos2(r.min.x + x0, r.min.y + 8.0), pos2(r.max.x - 16.0, r.min.y + 12.0));
if tr.width() < 40.0 {
return;
}
p.rect_filled(tr, 2.0, t.field_bg);
let f = ((v - slider_min) / (slider_max - slider_min)).clamp(0.0, 1.0) as f32;
p.rect_filled(Rect::from_min_max(tr.min, pos2(tr.min.x + tr.width() * f, tr.max.y)), 2.0, t.accent.gamma_multiply(0.7));
p.circle_filled(pos2(tr.min.x + tr.width() * f, tr.center().y), 5.0, t.text);
let dec = decimals as usize;
p.text(pos2(tr.min.x, tr.max.y + 4.0), Align2::LEFT_TOP, format!("{slider_min:.dec$}"), Tokens::ui(10.5), t.text_dim);
p.text(pos2(tr.max.x, tr.max.y + 4.0), Align2::RIGHT_TOP, format!("{slider_max:.dec$}"), Tokens::ui(10.5), t.text_dim);
let sresp = ui.interact(tr.expand2(vec2(4.0, 6.0)), egui::Id::unique(("ec-slider", uid)), Sense::click_and_drag());
app.auto.add(&format!("effectControls.prop.{uid}.slider"), tr, &prop.name);
if (sresp.dragged() || sresp.clicked())
&& let Some(pt) = sresp.interact_pointer_pos()
{
let f = ((pt.x - tr.min.x) / tr.width()).clamp(0.0, 1.0) as f64;
actions.push((
"prop.set".into(),
json!({"layer": layer.id.0, "prop": uid, "value": slider_min + (slider_max - slider_min) * f, "merge": format!("ec-{uid}")}),
));
}
}
/// The twirled-open angle dial under an angle param.
#[allow(clippy::too_many_arguments)]
fn dial_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, layer: &Layer, prop: &Property, ectx: &EvalCtx, r: Rect, x0: f32, actions: &mut Actions) {
let t = app.tokens;
let Value::Scalar(v) = ectx.value(layer, prop) else { return };
let uid = prop.uid;
let d = (r.height() - 12.0).min(52.0);
let dr = Rect::from_min_size(pos2(r.min.x + x0 + 40.0, r.min.y + 6.0), vec2(d, d));
let id = egui::Id::unique(("ec-dial", uid));
let (resp, nv) = fw::angle_dial(ui, dr, v, id, &t);
app.auto.add(&format!("effectControls.prop.{uid}.dial"), dr, &prop.name);
if let Some(nv) = nv {
let key = gesture_key(ui, id, resp.drag_started() || resp.clicked());
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": nv, "merge": key})));
}
}
/// The effect instance a row belongs to, for conditional visibility
/// ([`effectcraft_engine::effects::catalog::param_shown`]): effect id, the instance's root
/// group and the spec-id path of the group being drawn (`""` or `cameraPosition/`).
pub(super) struct FxScope<'a> {
pub(super) effect: &'a str,
pub(super) root: &'a PropGroup,
pub(super) path: String,
}
impl<'a> FxScope<'a> {
pub(super) fn sub(&self, m: &str) -> FxScope<'a> {
FxScope { effect: self.effect, root: self.root, path: format!("{}{m}/", self.path) }
}
/// Whether child `m` of the current group is shown.
pub(super) fn shown(&self, layer: &Layer, ectx: &EvalCtx, m: &str) -> bool {
let root = self.root;
let value = |id: &str| -> Option<Value> {
let mut g = root;
let mut parts = id.split('/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
return g.get(part).map(|p| ectx.value(layer, p));
}
g = g.sub(part)?;
}
None
};
effectcraft_engine::effects::catalog::param_shown(self.effect, &format!("{}{m}", self.path), &value)
}
}
/// Twirl `g` closed (it was `open`) or open; with `all` (Alt/Cmd-click) every group under it follows.
pub(super) fn toggle_closed(app: &mut EffectcraftApp, g: &PropGroup, open: bool, all: bool) {
fn walk(g: &PropGroup, out: &mut Vec<u64>) {
for sg in g.groups() {
out.push(sg.uid);
walk(sg, out);
}
}
let mut uids = vec![g.uid];
if all {
walk(g, &mut uids);
}
for u in uids {
if open {
app.ui.fx_closed.insert(u);
} else {
app.ui.fx_closed.remove(&u);
}
}
}
/// Lays out one property group's rows below `*y` (which it advances), recursing into open
/// subgroups: one row per visible property with its controls, plus the dial or slider row when its
/// slider is open, and an inline editor for groups that have one. Rows scrolled out of `rect` are
/// skipped without being drawn or registered, though their height is still counted. Pick-whip
/// drops are registered for each visible property; edits go on `actions`.
#[allow(clippy::too_many_arguments)]
pub(super) fn group_rows(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
layer: &Layer,
g: &PropGroup,
ectx: &EvalCtx,
rect: Rect,
y: &mut f32,
depth: usize,
fx: &FxScope,
actions: &mut Actions,
) {
let t = app.tokens;
for c in &g.children {
let r = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), ROW));
match c {
Node::Prop(pr) => {
if matches!(pr.ui, ParamUi::Hidden) || pr.three_d_only && !layer.is_3d() || !fx.shown(layer, ectx, &pr.match_id) {
continue;
}
*y += ROW;
let indent = PARAM_NAME_X - 13.0 + TREE_INDENT * depth as f32;
let visible = !(r.max.y < rect.min.y || r.min.y > rect.max.y);
if visible {
app.auto.add(&format!("effectControls.row.{}", pr.uid), r, &pr.name);
let dims = prop_row(app, ui, p, layer, g, pr, ectx, r, indent, actions);
// A Timeline pick whip can be dropped here (After Effects).
crate::panels::timeline::whip_target(ui.ctx(), r.intersect(rect), layer.id.0, pr.uid, dims);
}
if app.ui.fx_slider_open.contains(&pr.uid) {
if matches!(pr.ui, ParamUi::Angle) {
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), DIAL_ROW));
*y += DIAL_ROW;
if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y {
dial_row(app, ui, layer, pr, ectx, sr, indent + 22.0, actions);
}
} else if matches!(pr.ui, ParamUi::Slider { .. }) {
let sr = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), 34.0));
*y += 34.0;
if sr.max.y >= rect.min.y && sr.min.y <= rect.max.y {
slider_row(app, ui, p, layer, pr, ectx, sr, indent + 22.0, actions);
}
}
}
}
Node::Group(sg) => {
// Paint strokes live in the Timeline only (AE's Paint shows Paint on Transparent).
if effectcraft_engine::effects::paint::is_paint(g) || !fx.shown(layer, ectx, &sg.match_id) {
continue;
}
*y += ROW;
let open = !app.ui.fx_closed.contains(&sg.uid);
let tw = Rect::from_center_size(pos2(r.min.x + PARAM_NAME_X - 12.0 + TREE_INDENT * depth as f32, r.center().y), vec2(12.0, 12.0));
if r.max.y >= rect.min.y && r.min.y <= rect.max.y {
app.auto.add(&format!("effectControls.row.{}", sg.uid), r, &sg.name);
app.auto.add(&format!("effectControls.group.{}.twirl", sg.uid), tw, &sg.name);
if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-g", sg.uid)), &t).clicked() {
toggle_closed(app, sg, open, ui.input(|i| i.modifiers.alt || i.modifiers.command));
}
let name = p.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &sg.name, Tokens::ui(12.0), t.text);
app.auto.add(&format!("effectControls.group.{}.name", sg.uid), name, &sg.name);
}
if open {
let sub = fx.sub(&sg.match_id);
// A visual editor for the group's hidden parameters (Lumetri curves, …).
let eh = crate::panels::fx_editors::inline_height(fx.effect, &sub.path, rect.width());
if eh > 0.0 {
let er = Rect::from_min_size(pos2(rect.min.x, *y), vec2(rect.width(), eh));
*y += eh;
if er.max.y >= rect.min.y && er.min.y <= rect.max.y {
crate::panels::fx_editors::inline_editor(app, ui, p, layer, fx.root, sg, fx.effect, &sub.path, ectx, er, actions);
}
}
group_rows(app, ui, p, layer, sg, ectx, rect, y, depth + 1, &sub, actions);
}
}
}
}
}
@@ -0,0 +1,254 @@
use super::common::{Actions, effect_active, gesture_key};
use super::rows::FxScope;
use super::*;
// ---------------------------------------------------------------------------------------------
// Viewer hook
/// Collect point controls through visible parameter groups. Hidden optional slots do not
/// leave stray viewport handles. The depth cap also bounds malformed project trees.
fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx: &EvalCtx, depth: usize, out: &mut Vec<&'a Property>) {
if depth > 16 {
return;
}
for node in &g.children {
match node {
Node::Prop(p) if matches!(p.ui, ParamUi::Point) && scope.shown(layer, ectx, &p.match_id) => out.push(p),
Node::Group(child) if child.enabled && scope.shown(layer, ectx, &child.match_id) => {
visible_points(child, &scope.sub(&child.match_id), layer, ectx, depth + 1, out)
}
_ => {}
}
}
}
/// Effect Controls' part of the Composition viewer, called by the viewer after its own
/// interaction (so these controls sit on top):
///
/// - while a crosshair pick is armed, its handle previews the cursor position without editing
/// the property; the next click sets the point in layer space, and Esc cancels;
/// - an eyedropper pick samples the frame's colour on click;
/// - otherwise, the point parameters of the selected effects are drawn as draggable ⊕ controls.
///
/// `l2c` is the viewer's layer→comp matrix (it knows the 3D view).
pub fn viewer_hook(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &crate::panels::viewer::ViewerMap,
ectx: &EvalCtx,
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut actions: Actions = vec![];
let mut point_preview = None;
if let Some(pick) = app.ui.fx_pick.clone() {
let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else {
app.ui.fx_pick = None;
return;
};
// Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`.
let keyer = (pick.kind == "color")
.then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some()))
.flatten()
.filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying"))
.map(|g| g.uid);
if pick.kind == "color" && keyer.is_none() {
// GPU frames: read the shown frame back for sampling.
app.viewer_pixels();
}
let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click());
app.auto.add("viewer.fxPick", map.area, &pick.name);
let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) };
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text);
if let Some(hp) = resp.hover_pos() {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
let c = map.to_comp(hp);
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() {
point_preview = Some(hp);
}
if pick.kind == "color"
&& keyer.is_none()
&& let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c)
{
let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0));
painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8));
painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside);
}
}
// No frame to sample yet (it is still rendering, or its readback is pending): the click
// leaves the eyedropper armed instead of using it up without a colour.
let unsampled = pick.kind == "color" && keyer.is_none() && app.viewer_image.is_none();
if resp.clicked()
&& !unsampled
&& let Some(pos) = resp.interact_pointer_pos()
{
let c = map.to_comp(pos);
// Effect points and the keyer's input are in effect space.
let m = crate::panels::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer));
if pick.kind == "point" {
if let Some(lp) = fw::comp_to_layer(&m, c) {
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]})));
}
} else if let Some(fx) = keyer {
// Ctrl/Cmd+click averages the 5 × 5 pixels around the point.
if let Some(lp) = fw::comp_to_layer(&m, c) {
let average = ui.input(|i| i.modifiers.command);
actions.push((
"effect.pickColor".into(),
json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}),
));
}
} else if let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c)
{
actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]})));
}
app.ui.fx_pick = None;
app.ui.status.clear();
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.fx_pick = None;
app.ui.status.clear();
}
}
if let Some(pick) = app.ui.text_color_pick.clone() {
if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() {
app.ui.text_color_pick = None;
app.ui.status.clear();
} else {
app.viewer_pixels();
let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click());
app.auto.add("viewer.textColorPick", map.area, &pick.field);
let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0));
painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180));
painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text);
if let Some(pos) = resp.hover_pos() {
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
let c = map.to_comp(pos);
app.pointer_comp = Some([c[0] as f32, c[1] as f32]);
if let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c)
{
let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0));
painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8));
painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside);
}
}
if resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some(img) = &app.viewer_image
&& let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos))
{
let mut params = json!({"layer": pick.layer});
params[&pick.field] = json!([s[0], s[1], s[2]]);
if let Some(range) = pick.range {
params["range"] = json!(range);
}
actions.push(("layer.setText".into(), params));
app.ui.text_color_pick = None;
app.ui.status.clear();
}
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.text_color_pick = None;
app.ui.status.clear();
}
}
}
let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some();
let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point");
if app.ui.viewer.show_layer_controls || point_pick.is_some() {
// ⊕ controls for the point parameters of the selected effects.
let mut sel = app.session.state.selected_props.clone();
if let Some(pick) = &point_pick {
sel.push((effectcraft_engine::project::LayerId(pick.layer), pick.prop));
}
let selected = app.session.state.selected_layers.clone();
for layer in ectx
.comp
.layers
.iter()
.filter(|l| (selected.contains(&l.id) || point_pick.as_ref().is_some_and(|p| p.layer == l.id.0)) && l.is_active_at(ectx.time))
{
let Some(fx) = layer.effects() else { continue };
let m = l2c(ectx, layer);
// Masks are in layer space, effect points in effect space.
let fm = crate::panels::viewer::from_effect_space(ectx, layer, m);
let picked_prop = point_pick.as_ref().filter(|p| p.layer == layer.id.0).map(|p| p.prop);
for g in fx.groups().filter(|g| g.enabled && effect_active(g, layer.id, &sel)) {
// Puppet owns its pin handles and hit testing. Generic crosshairs would
// overlap the pins and steal their selection/rotation/scale gestures.
if matches!(&g.kind, GroupKind::Effect { effect } if effect == effectcraft_engine::effects::puppet::ID) {
continue;
}
if !picking
&& app.ui.viewer.show_layer_controls
&& matches!(&g.kind, GroupKind::Effect { effect } if effect == crate::panels::fx_editors::RESHAPE)
{
crate::panels::fx_editors::reshape_overlay(app, ui, painter, map, ectx, layer, g, &m, &mut actions);
}
if !picking
&& app.ui.viewer.show_layer_controls
&& matches!(&g.kind, GroupKind::Effect { effect } if effect == crate::panels::fx_editors::MESH_WARP)
{
crate::panels::fx_editors::mesh_warp_overlay(app, ui, painter, map, ectx, layer, g, &fm, &mut actions);
}
let scope = FxScope { effect: &g.match_id, root: g, path: String::new() };
let mut pts = Vec::new();
visible_points(g, &scope, layer, ectx, 0, &mut pts);
// An explicit pick stays visible even when View > Show Layer Controls is off.
if !app.ui.viewer.show_layer_controls {
pts.retain(|pr| picked_prop == Some(pr.uid));
}
let screen: Vec<egui::Pos2> = pts
.iter()
.map(|pr| {
if picked_prop == Some(pr.uid)
&& let Some(pos) = point_preview
{
return pos;
}
let v = ectx.value(layer, pr).as_vec2();
map.to_screen(fw::layer_to_comp(&fm, v))
})
.collect();
// Gradient Ramp and friends: a guide between start and end points.
if screen.len() == 2 && matches!(&g.kind, GroupKind::Effect { effect } if effect.contains("gradient") || effect.contains("ramp")) {
painter.line_segment([screen[0], screen[1]], Stroke::new(1.0, Color32::from_white_alpha(110)));
}
for (pr, s) in pts.iter().zip(screen) {
let hr = Rect::from_center_size(s, vec2(16.0, 16.0));
let id = egui::Id::unique(("viewer-fx-pt", pr.uid));
// The picker owns the click. Preview handles must not steal it or start a drag.
let resp = (!picking).then(|| ui.interact(hr, id, Sense::drag()));
let col = if picked_prop == Some(pr.uid) || resp.as_ref().is_some_and(|r| r.hovered() || r.dragged()) { t.accent } else { Color32::WHITE };
fw::draw_crosshair(painter, s + vec2(1.0, 1.0), 5.0, Color32::from_black_alpha(160));
fw::draw_crosshair(painter, s, 5.0, col);
app.auto.add(&format!("viewer.effectPoint.{}", pr.uid), hr, &pr.name);
let Some(resp) = resp else { continue };
if resp.hovered() {
ctx.set_cursor_icon(egui::CursorIcon::Move);
resp.clone().on_hover_text(format!("{}: {}", crate::i18n::effects::group(&g.match_id, &g.name), pr.name));
}
if resp.dragged()
&& let Some(pos) = resp.interact_pointer_pos()
&& let Some(lp) = fw::comp_to_layer(&fm, map.to_comp(pos))
{
let key = gesture_key(ui, id, resp.drag_started());
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": pr.uid, "value": [lp[0], lp[1]], "merge": key})));
}
}
}
}
}
for (id, params) in actions {
if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
app.ui.status = e;
}
}
}