mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-28 12:07:48 -06:00
refactor(ui-egui): move Effect Controls panel parts to panels/effect_controls/*.rs
This commit is contained in:
@@ -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(¶ms);
|
||||
let d = fx::data(¶ms);
|
||||
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(¶ms);
|
||||
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(¶ms);
|
||||
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, ¶ms, 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user