Refactor ui-egui for strict Clippy checks

This commit is contained in:
Dylan M. Taylor committed 2026-10-28 06:09:21 +00:00
1 parent f20d22bf36
commit 6e72ab3b57
47 files changed
+6246 -3812

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Dialog, EffectcraftApp};
impl EffectcraftApp {
/// Show the crash-recovery dialog for a previous run that didn't exit cleanly.
+2 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels};
impl EffectcraftApp {
pub fn render_source(&self) -> RenderSource {
@@ -184,6 +184,7 @@ impl EffectcraftApp {
#[cfg(test)]
mod gpu_failure_tests {
use super::*;
use crate::{FrameKey, ItemId, RenderOpts, Session, Tick, json};
#[test]
fn host_failure_preserves_document_undo_and_cpu_pixels_and_detaches_late_readbacks() {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames};
/// Seconds without a project change before paused prefetch resumes (see
/// [`EffectcraftApp::editing`]).
+2 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectcraftApp, Tokens, json, theme};
impl EffectcraftApp {
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
@@ -67,6 +67,7 @@ impl EffectcraftApp {
#[cfg(test)]
mod appearance_tests {
use super::*;
use crate::menus;
use crate::theme::ThemeKind;
fn app() -> EffectcraftApp {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{DrawGeometry, EffectcraftApp, PendingShot, Sender, ShotTarget, control, json};
/// How long a screenshot request waits for a frame, from admission (app clock seconds).
pub(crate) const SCREENSHOT_TIMEOUT_S: f64 = 8.0;
+217 -120
View File
@@ -25,11 +25,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_filled(rect, 0.0, t.header_bg);
p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.app_bg));
let cy = rect.center().y;
let mut x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 };
let x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 };
let x = brand_and_home(app, ui, &p, x, cy);
let x = tool_slots(app, ui, &p, x, cy);
let x = tool_options_bar(app, ui, &p, x + 10.0, cy);
let x = snapping_toggle(app, ui, &p, x, cy);
right_side(app, ui, &p, rect, x, cy);
}
// Brand mark.
/// The brand mark (About) and the Home button; returns the x after them.
fn brand_and_home(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
let t = app.tokens;
let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0));
paint_logo(&p, brand);
paint_logo(p, brand);
let bresp = ui.interact(brand, egui::Id::unique("brand"), Sense::click());
app.auto.add_response("header.about", &bresp, "About EffectCraft");
widgets::focus_outline(ui, &bresp, &t);
@@ -38,7 +46,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
x += 32.0;
// Home.
let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::unique("tool-home")).on_hover_text(tr("Home"));
app.auto.add_response("header.home", &home_response, "Home");
@@ -47,78 +54,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
x += 30.0;
p.line_segment([pos2(x, cy - 10.0), pos2(x, cy + 10.0)], Stroke::new(1.0, t.separator));
x += 6.0;
x + 6.0
}
// Tool slots (with group separators after camera tools and pan-behind).
/// The tool slots (with group separators after camera tools and pan-behind); returns the x after them.
fn tool_slots(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
let t = app.tokens;
let held_id = egui::Id::unique("tool-slot-held");
if ui.input(|i| i.pointer.any_pressed()) {
ui.data_mut(|d| d.remove::<egui::Id>(held_id));
}
for (si, slot) in Tool::SLOTS.iter().enumerate() {
let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]);
let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0));
let active = slot.contains(&app.ui.tool);
let id = egui::Id::unique(("tool-slot", si));
let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id);
if slot.len() > 1 {
// Flyout corner triangle.
p.add(egui::Shape::convex_polygon(
vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)],
if active { Color32::WHITE } else { t.text_dim },
Stroke::NONE,
));
}
app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label());
let tip = match cur.shortcut() {
Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)),
None => cur.label().to_string(),
};
let resp = resp.on_hover_text(tip);
let held = ui.data(|d| d.get_temp::<egui::Id>(held_id)) == Some(id);
if resp.clicked() && !held {
app.ui.tool = cur;
}
// Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content.
if cur == Tool::PanBehind
&& resp.double_clicked()
&& ui.input(|i| i.modifiers.command)
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({}))
{
app.ui.status = e;
}
if slot.len() > 1 {
let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten();
if let Some(seconds) = held_for
&& seconds < 0.35
{
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
}
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held;
let command = if resp.secondary_clicked() || long_press {
if long_press {
ui.data_mut(|d| d.insert_temp(held_id, id));
}
Some(egui::SetOpenCommand::Bool(true))
} else if resp.clicked() && !held {
Some(egui::SetOpenCommand::Bool(false))
} else {
None
};
let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside };
egui::Popup::menu(&resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| {
for tool in slot.iter() {
let row = ui.selectable_label(app.ui.tool == *tool, tool.label());
app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label());
if row.clicked() {
app.ui.tool = *tool;
if let Some(saved) = app.ui.slot_tools.get_mut(si) {
*saved = *tool;
}
ui.close();
}
}
});
}
tool_slot(app, ui, p, si, slot, r, held_id);
x += 28.0;
if matches!(si, 2 | 5 | 7 | 10 | 13) {
x += 4.0;
@@ -126,17 +74,96 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
x += 6.0;
}
}
x
}
// Tool options.
x += 10.0;
// Selected shape layers: the options show their paint and edit it (as in After Effects).
let shape_layers: Vec<u64> = app
.session
/// One tool slot button in `r`: select on click, Ctrl+double-click Pan Behind, and the fly-out.
fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: usize, slot: &[Tool], r: Rect, held_id: egui::Id) {
let t = app.tokens;
let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]);
let active = slot.contains(&app.ui.tool);
let id = egui::Id::unique(("tool-slot", si));
let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id);
if slot.len() > 1 {
// Flyout corner triangle.
p.add(egui::Shape::convex_polygon(
vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)],
if active { Color32::WHITE } else { t.text_dim },
Stroke::NONE,
));
}
app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label());
let tip = match cur.shortcut() {
Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)),
None => cur.label().to_string(),
};
let resp = resp.on_hover_text(tip);
let held = ui.data(|d| d.get_temp::<egui::Id>(held_id)) == Some(id);
if resp.clicked() && !held {
app.ui.tool = cur;
}
// Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content.
if cur == Tool::PanBehind
&& resp.double_clicked()
&& ui.input(|i| i.modifiers.command)
&& let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({}))
{
app.ui.status = e;
}
if slot.len() > 1 {
slot_flyout(app, ui, si, slot, &resp, held_id);
}
}
/// The fly-out menu of a multi-tool slot: right-click or press-and-hold opens it.
fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[Tool], resp: &egui::Response, held_id: egui::Id) {
let id = resp.id;
let held = ui.data(|d| d.get_temp::<egui::Id>(held_id)) == Some(id);
let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten();
if let Some(seconds) = held_for
&& seconds < 0.35
{
ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds));
}
let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held;
let command = if resp.secondary_clicked() || long_press {
if long_press {
ui.data_mut(|d| d.insert_temp(held_id, id));
}
Some(egui::SetOpenCommand::Bool(true))
} else if resp.clicked() && !held {
Some(egui::SetOpenCommand::Bool(false))
} else {
None
};
let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside };
egui::Popup::menu(resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| {
for tool in slot.iter() {
let row = ui.selectable_label(app.ui.tool == *tool, tool.label());
app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label());
if row.clicked() {
app.ui.tool = *tool;
if let Some(saved) = app.ui.slot_tools.get_mut(si) {
*saved = *tool;
}
ui.close();
}
}
});
}
/// The ids of the selected shape layers: the options show their paint and edit it.
fn selected_shape_layers(app: &EffectcraftApp) -> Vec<u64> {
app.session
.active_comp()
.map(|c| {
app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect()
})
.unwrap_or_default();
.unwrap_or_default()
}
/// Mirrors the first selected shape layer's paint into the shape tool (as in After Effects).
fn sync_layer_paint(app: &mut EffectcraftApp, shape_layers: &[u64]) {
let layer_paint = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(|l| {
let lt = l.layer_time(app.session.time());
(effectcraft_engine::commands::shape_stroke::first_paint(l, lt), effectcraft_engine::commands::shape_stroke::first_paint_kinds(l, lt))
@@ -154,6 +181,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
tool.stroke_width = width.unwrap_or(0.0);
}
}
/// Options for the active tool (shape creation, fill and stroke, puppet, roto); returns the x after them.
fn tool_options_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 {
// Selected shape layers: the options show their paint and edit it (as in After Effects).
let shape_layers = selected_shape_layers(app);
sync_layer_paint(app, &shape_layers);
// One undo step per picker session or width drag: end the merge once both are done.
let picking = ["fill", "stroke"].iter().any(|k| widgets::popup_is_open(ui, picker_id(k)));
if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) {
@@ -167,14 +201,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Fill and Stroke, hidden while the tools draw masks.
let masking = drawing && !shape_layers.is_empty() && app.session.state.shape_tool.creates_mask;
if (drawing || !shape_layers.is_empty()) && !masking {
x = fill_stroke_options(app, ui, &p, &shape_layers, x, cy);
x = fill_stroke_options(app, ui, p, &shape_layers, x, cy);
}
if app.ui.tool.puppet_kind().is_some() {
x = puppet_options(app, ui, &p, x, cy);
x = puppet_options(app, ui, p, x, cy);
}
if app.ui.tool.roto_kind(false).is_some() {
x = roto_options(app, ui, &p, x, cy);
x = roto_options(app, ui, p, x, cy);
}
x
}
/// The Snapping checkbox, its label and options menu; returns the x where it started.
fn snapping_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: f32, cy: f32) -> f32 {
let t = app.tokens;
let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0));
// The engine owns snapping (View ▸ Snapping); the checkbox mirrors it.
app.ui.snapping = app.session.state.snapping;
@@ -185,9 +225,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add("header.snapping", snap, "Snapping");
let label = p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, tr("Snapping"), Tokens::ui(12.0), t.text_dim);
snapping_options(app, ui, Rect::from_min_size(pos2(label.max.x + 2.0, cy - 9.0), vec2(18.0, 18.0)));
x
}
// Right side: community buttons, workspaces.
let mut rx = rect.max.x - 10.0;
/// Right side: community buttons, appearance and workspaces; `left` is where the tools end.
fn right_side(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rect: Rect, left: f32, cy: f32) {
let t = app.tokens;
let rx = community_buttons(app, ui, p, rect.max.x - 10.0, cy);
let mut rx = appearance_button(app, ui, rx, cy);
rx -= 10.0;
p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator));
rx -= 10.0;
let rx = workspace_menu(app, ui, p, rx, cy);
workspace_tabs(app, ui, p, rx, left, cy);
}
/// The Discord pill and the GitHub and website buttons, right to left from `rx`; returns the new `rx`.
fn community_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, cy: f32) -> f32 {
let t = app.tokens;
// The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the
// text at its left, which left a wide gap on the right (#581).
let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE);
@@ -195,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let dresp = ui.interact(discord, egui::Id::unique("hdr-discord"), Sense::click());
let dc = Color32::from_rgb(0x58, 0x65, 0xf2);
p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc });
icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
icons::paint(p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE);
p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE);
app.auto.add("header.discord", discord, "Join the ArtCraft Discord");
if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() {
@@ -212,7 +267,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add(&format!("header.{}", &id[4..]), r, tip);
rx = r.min.x - 4.0;
}
// Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next.
rx
}
/// Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next;
/// returns the new `rx`.
fn appearance_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, rx: f32, cy: f32) -> f32 {
let t = app.tokens;
let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() {
"auto" => (Icon::Monitor, tr("Sync with System")),
"light" => (Icon::Sun, tr("Light")),
@@ -224,37 +285,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if response.clicked() {
app.cycle_appearance(ui.ctx());
}
response.context_menu(|ui| {
let settings = ui.button(tr("Appearance Settings…"));
app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…");
if settings.clicked() {
crate::panels::settings::open(app, "appearance");
ui.close();
}
let favorites = app.session.prefs.appearance.favorite_themes.clone();
for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] {
let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect();
if !group.is_empty() {
ui.separator();
}
for (label, id) in group {
let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label));
app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label);
if r.clicked() {
if let Some(kind) = crate::theme::ThemeKind::from_name(id) {
app.set_theme(ui.ctx(), kind);
}
ui.close();
}
}
}
});
response.context_menu(|ui| appearance_menu(app, ui));
app.auto.add("header.appearance", r, &tip);
rx = r.min.x - 4.0;
rx -= 10.0;
p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator));
rx -= 10.0;
// Workspace tabs (right to left), with a » menu of all.
r.min.x - 4.0
}
/// The appearance button's context menu: settings and the favorite themes.
fn appearance_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let settings = ui.button(tr("Appearance Settings…"));
app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…");
if settings.clicked() {
crate::panels::settings::open(app, "appearance");
ui.close();
}
let favorites = app.session.prefs.appearance.favorite_themes.clone();
for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] {
let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect();
if !group.is_empty() {
ui.separator();
}
for (label, id) in group {
let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label));
app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label);
if r.clicked() {
if let Some(kind) = crate::theme::ThemeKind::from_name(id) {
app.set_theme(ui.ctx(), kind);
}
ui.close();
}
}
}
}
/// The » button with the menu of all workspaces; returns the new `rx`.
fn workspace_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rx: f32, cy: f32) -> f32 {
let t = app.tokens;
let more = Rect::from_min_max(pos2(rx - 20.0, cy - 11.0), pos2(rx, cy + 11.0));
let mresp = ui.interact(more, egui::Id::unique("ws-more"), Sense::click());
p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim });
@@ -273,15 +338,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
) {
app.set_workspace(crate::dock::WORKSPACES[i]);
}
rx = more.min.x - 6.0;
// After Effects 2026's workspace bar order (right to left here): Default, Review, Learn,
// Small Screen, Standard.
more.min.x - 6.0
}
/// Workspace tabs, right to left from `rx`, stopping 120 px right of `left`.
/// After Effects 2026's order (right to left here): Default, Review, Learn, Small Screen, Standard.
fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, left: f32, cy: f32) {
let t = app.tokens;
let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"];
for name in shown {
let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text);
let w = g.size().x + 16.0;
let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0));
if r.min.x < x + 120.0 {
if r.min.x < left + 120.0 {
break;
}
let active = app.ui.workspace == name;
@@ -439,7 +508,14 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa
x = sr.max.x + 12.0;
}
let width = app.session.state.shape_tool.stroke_width;
let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::unique("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t);
let (r, v, _) = widgets::hot_number_at(
ui,
pos2(x - 6.0, cy - 9.0),
egui::Id::unique("tool-stroke-w"),
width,
&widgets::HotSpec { speed: 0.2, range: (0.0, 1000.0), decimals: 0, suffix: " px" },
&t,
);
if let Some(v) = v {
tool_options(app, json!({"strokeWidth": v}));
paint(app, "strokeWidth", json!(v));
@@ -506,7 +582,14 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers
}
app.auto.add(&format!("header.{key}Options.blend"), blend_rect, "Blend Mode");
p.text(row(98.0), Align2::LEFT_CENTER, tr("Opacity:"), Tokens::ui(12.0), t.text_dim);
let (or, v, _) = widgets::hot_number_at(ui, row(89.0) + vec2(84.0, 0.0), id.with("opacity"), tp.opacity, 0.5, (0.0, 100.0), 0, "%", &t);
let (or, v, _) = widgets::hot_number_at(
ui,
row(89.0) + vec2(84.0, 0.0),
id.with("opacity"),
tp.opacity,
&widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "%" },
&t,
);
app.auto.add(&format!("header.{key}Options.opacity"), or, "Opacity");
if let Some(v) = v {
let k_opacity = format!("{key}Opacity");
@@ -577,7 +660,14 @@ fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
let label = tr("Diameter:");
let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
x = lr.max.x + 6.0;
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x, cy - 9.0),
egui::Id::unique(("roto-opt", key)),
v,
&widgets::HotSpec { speed: 0.5, range: (1.0, 5000.0), decimals: 0, suffix: " px" },
&t,
);
app.auto.add("header.roto.diameter", r, label);
if let Some(nv) = nv
&& let Err(e) = app.session.execute("roto.options", json!({key: nv}))
@@ -604,7 +694,14 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] {
p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim);
x += if key == "expansion" { 64.0 } else { 52.0 };
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("puppet-opt", key)), v, 0.2, range, 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x, cy - 9.0),
egui::Id::unique(("puppet-opt", key)),
v,
&widgets::HotSpec { speed: 0.2, range, decimals: 0, suffix: "" },
&t,
);
app.auto.add(&format!("header.puppet.{key}"), r, label);
if let Some(nv) = nv {
let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")}));
+1 -1
View File
@@ -1,7 +1,7 @@
use super::entries::{entry_checked, entry_enabled, entry_label, entry_shortcut, menu_choice};
use super::invoke::invoke;
use super::shortcuts::shortcut_text;
use super::*;
use super::{EffectcraftApp, MenuEntry, MenuNode, Value, json};
/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last
/// frame (its title's open look, and the hover that switches between the titles).
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::UiCommand;
macro_rules! uic {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr) => {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectcraftApp, PanelKind, Resolution, Value, json};
/// Run an engine command and perform the frontend events it emitted right away.
/// Commands that fill the internal clipboard.
+81 -60
View File
@@ -1,6 +1,6 @@
use super::frontend::run_engine;
use super::invoke::invoke;
use super::*;
use super::{EffectcraftApp, PanelKind, Value, json};
/// Shortcut text in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere).
pub fn shortcut_text(s: &str) -> String {
@@ -197,54 +197,40 @@ pub fn handle_tab_shortcut(app: &mut EffectcraftApp, ctx: &egui::Context) {
}
}
fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) {
let start = modifiers_at_start(ctx);
// Dialog cancellation owns Escape even when a text field has keyboard focus.
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
// A popup or colour picker takes Escape first (the picker goes back to its original colour).
if crate::panels::shortcut_editor::recording(app)
|| crate::panels::shortcut_editor::confirming(app)
|| egui::Popup::is_any_open(ctx)
|| crate::color_picker::open_recently(ctx)
/// Escape with a dialog open: close the popup/picker first, else cancel the dialog.
fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) {
// A popup or colour picker takes Escape first (the picker goes back to its original colour).
if crate::panels::shortcut_editor::recording(app)
|| crate::panels::shortcut_editor::confirming(app)
|| egui::Popup::is_any_open(ctx)
|| crate::color_picker::open_recently(ctx)
{
return;
}
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if app.dialog == Some(crate::Dialog::LayerStyles) {
crate::panels::layer_styles_dialog::finish(app, false);
} else if app.dialog == Some(crate::Dialog::Info) {
crate::panels::forms::close_info(app);
} else {
if app.dialog == Some(crate::Dialog::Settings)
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
{
return;
app.session.prefs = p;
app.session.prefs_changed();
}
ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if app.dialog == Some(crate::Dialog::LayerStyles) {
crate::panels::layer_styles_dialog::finish(app, false);
} else if app.dialog == Some(crate::Dialog::Info) {
crate::panels::forms::close_info(app);
} else {
if app.dialog == Some(crate::Dialog::Settings)
&& let Some(p) = app.dialog_state.prefs_snapshot.take()
{
app.session.prefs = p;
app.session.prefs_changed();
}
if app.dialog == Some(crate::Dialog::UnsavedChanges) {
app.dialog_state.unsaved.command = None;
}
app.dialog = None;
if app.dialog == Some(crate::Dialog::UnsavedChanges) {
app.dialog_state.unsaved.command = None;
}
return;
app.dialog = None;
}
// A mouse button down while Spacebar is held belongs to the Hand tool: its release won't
// preview.
if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() {
ctx.data_mut(|d| d.remove::<bool>(space_tap_id()));
}
// While a menu or popup list is open, keys go to it, not to the app (Space must not start a
// preview behind the menu, #279).
if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) {
return;
}
// Text editing in the viewer takes the clipboard events itself.
let clipboard = app.session.state.text_edit.is_none();
let project = app.ui.focused == PanelKind::Project;
// The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer.
let pasted =
if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None };
let mut events: Vec<(egui::Key, egui::Modifiers, bool, Option<egui::Key>)> = ctx.input(|i| {
}
type KeyEvent = (egui::Key, egui::Modifiers, bool, Option<egui::Key>);
/// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V.
fn collect_key_events(ctx: &egui::Context, start: Option<egui::Modifiers>, clipboard: bool, project: bool) -> Vec<KeyEvent> {
ctx.input(|i| {
// The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of
// key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut,
// Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and
@@ -283,22 +269,57 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
}
}
out
});
})
}
/// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it
/// comes as text.
fn numpad_star_marker(app: &mut EffectcraftApp, ctx: &egui::Context) {
if !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) {
return;
}
let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone()));
// As in After Effects, it marks the selected layers; only with none selected does it add
// a composition marker (#976).
let bound =
bound.map(
|(id, params)| {
if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) }
},
);
if let Some((id, params)) = bound
&& let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params })
{
app.ui.status = e;
}
}
fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) {
let start = modifiers_at_start(ctx);
if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
escape_dialog(app, ctx);
return;
}
// A mouse button down while Spacebar is held belongs to the Hand tool: its release won't
// preview.
if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() {
ctx.data_mut(|d| d.remove::<bool>(space_tap_id()));
}
// While a menu or popup list is open, keys go to it, not to the app (Space must not start a
// preview behind the menu, #279).
if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) {
return;
}
// Text editing in the viewer takes the clipboard events itself.
let clipboard = app.session.state.text_edit.is_none();
let project = app.ui.focused == PanelKind::Project;
// The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer.
let pasted =
if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None };
let mut events = collect_key_events(ctx, start, clipboard, project);
events.retain(|(key, _, _, _)| (*key == egui::Key::Tab) == tab_only);
// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it
// comes as text.
if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) {
let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone()));
// As in After Effects, it marks the selected layers; only with none selected does it add
// a composition marker (#976).
let bound = bound.map(|(id, params)| {
if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) }
});
if let Some((id, params)) = bound
&& let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params })
{
app.ui.status = e;
}
if !tab_only && clipboard && app.dialog.is_none() {
numpad_star_marker(app, ctx);
}
if events.is_empty() {
return;
+61 -11
View File
@@ -33,9 +33,9 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti
/// A drag-to-change number field for an anim-tool setting: `v` clamped to `range`, `speed` points
/// per pixel, `dec` decimals and a `suffix`; returns the new value, or `v` when it did not change.
fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, spec: widgets::HotSpec) -> f64 {
let t = app.tokens;
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, speed, range, dec, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, &spec, &t);
app.auto.add(auto, r, &format!("{v}"));
nv.unwrap_or(v)
}
@@ -105,10 +105,24 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += ROW + 4.0;
label(&p, x0, y, "Frequency:", &t);
st.wiggle_frequency = number(app, ui, pos2(xv, y), "wiggler.frequency", st.wiggle_frequency, 0.1, (0.1, 100.0), 1, " per second");
st.wiggle_frequency = number(
app,
ui,
pos2(xv, y),
"wiggler.frequency",
st.wiggle_frequency,
widgets::HotSpec { speed: 0.1, range: (0.1, 100.0), decimals: 1, suffix: " per second" },
);
y += ROW;
label(&p, x0, y, "Magnitude:", &t);
st.wiggle_magnitude = number(app, ui, pos2(xv, y), "wiggler.magnitude", st.wiggle_magnitude, 0.5, (0.0, 100_000.0), 1, "");
st.wiggle_magnitude = number(
app,
ui,
pos2(xv, y),
"wiggler.magnitude",
st.wiggle_magnitude,
widgets::HotSpec { speed: 0.5, range: (0.0, 100_000.0), decimals: 1, suffix: "" },
);
y += ROW + 8.0;
app.ui.anim_tools = st.clone();
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
@@ -149,7 +163,14 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, txt, Tokens::ui(12.0), t.text);
y += ROW;
label(&p, x0, y, "Tolerance:", &t);
let tol = number(app, ui, pos2(xv, y), "smoother.tolerance", app.ui.anim_tools.smooth_tolerance, 0.05, (0.0, 10_000.0), 2, "");
let tol = number(
app,
ui,
pos2(xv, y),
"smoother.tolerance",
app.ui.anim_tools.smooth_tolerance,
widgets::HotSpec { speed: 0.05, range: (0.0, 10_000.0), decimals: 2, suffix: "" },
);
app.ui.anim_tools.smooth_tolerance = tol;
y += ROW + 8.0;
let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0));
@@ -170,10 +191,24 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut y = rect.min.y + 10.0;
let mut st = app.ui.anim_tools.clone();
label(&p, x0, y, "Capture speed at:", &t);
st.sketch_speed = number(app, ui, pos2(xv, y), "motionSketch.captureSpeed", st.sketch_speed, 1.0, (1.0, 10_000.0), 0, " %");
st.sketch_speed = number(
app,
ui,
pos2(xv, y),
"motionSketch.captureSpeed",
st.sketch_speed,
widgets::HotSpec { speed: 1.0, range: (1.0, 10_000.0), decimals: 0, suffix: " %" },
);
y += ROW;
label(&p, x0, y, "Smoothing:", &t);
st.sketch_smoothing = number(app, ui, pos2(xv, y), "motionSketch.smoothing", st.sketch_smoothing, 0.1, (0.0, 100.0), 1, "");
st.sketch_smoothing = number(
app,
ui,
pos2(xv, y),
"motionSketch.smoothing",
st.sketch_smoothing,
widgets::HotSpec { speed: 0.1, range: (0.0, 100.0), decimals: 1, suffix: "" },
);
y += ROW + 2.0;
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, tr("Show:"), Tokens::ui(12.0), t.text_dim);
y += ROW - 6.0;
@@ -328,7 +363,14 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
changes.insert("keyframeRate".into(), if i == 0 { json!("auto") } else { json!(fps) });
}
if let Some(r) = o.keyframe_rate {
let nr = number(app, ui, pos2(xv + 78.0, y), "maskInterp.rate", r, 0.5, (0.1, 1000.0), 1, " per second");
let nr = number(
app,
ui,
pos2(xv + 78.0, y),
"maskInterp.rate",
r,
widgets::HotSpec { speed: 0.5, range: (0.1, 1000.0), decimals: 1, suffix: " per second" },
);
if (nr - r).abs() > 1e-9 {
changes.insert("keyframeRate".into(), json!(nr));
}
@@ -346,13 +388,20 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
check(app, y, "linearVertexPaths", "Use \"Linear\" Vertex Paths", o.linear_vertex_paths);
y += ROW;
label(&p, x0, y, "Bending Resistance:", &t);
let br = number(app, ui, pos2(xv, y), "maskInterp.bendingResistance", o.bending_resistance, 0.5, (0.0, 100.0), 0, "");
let br = number(
app,
ui,
pos2(xv, y),
"maskInterp.bendingResistance",
o.bending_resistance,
widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" },
);
if (br - o.bending_resistance).abs() > 1e-9 {
changes.insert("bendingResistance".into(), json!(br));
}
y += ROW;
label(&p, x0, y, "Quality:", &t);
let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, 0.5, (0.0, 100.0), 0, "");
let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" });
if (q - o.quality).abs() > 1e-9 {
changes.insert("quality".into(), json!(q));
}
@@ -363,7 +412,8 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec
}
y += ROW;
if let Some(v) = o.add_vertices {
let nv = number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, 0.2, (0.1, 2000.0), 1, "");
let nv =
number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, widgets::HotSpec { speed: 0.2, range: (0.1, 2000.0), decimals: 1, suffix: "" });
if (nv - v).abs() > 1e-9 {
changes.insert("addVertices".into(), json!(nv));
}
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectcraftApp, Layer, Node, ParamUi, PropGroup, Value, json};
pub(super) type Actions = Vec<(String, serde_json::Value)>;
@@ -1,5 +1,5 @@
use super::common::{Actions, gesture_key};
use super::*;
use super::{Align2, Color32, EffectcraftApp, EvalCtx, Layer, PropGroup, Rect, Sense, Tokens, Value, fw, json, pos2, vec2, widgets};
/// 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) {
@@ -1,7 +1,7 @@
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::*;
use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets};
// ---------------------------------------------------------------------------------------------
@@ -13,6 +13,140 @@ pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) {
ctx.memory_mut(|m| m.request_focus(id.with("field")));
}
/// Where an effect header's parts sit, for its rename field.
struct EffectHdr<'a> {
g: &'a PropGroup,
shown: &'a str,
r: Rect,
tw: Rect,
reset: Rect,
rename_id: egui::Id,
layer_id: u64,
}
/// The effect header's name field while renaming: Enter or blur commits, Escape cancels.
fn rename_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, h: &EffectHdr, mut value: String, actions: &mut Actions) {
let EffectHdr { g, shown, r, tw, reset, rename_id, layer_id } = *h;
let ctx = ui.ctx().clone();
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, "prop": g.uid, "name": value})));
}
} else {
ctx.data_mut(|d| d.insert_temp(rename_id, value));
}
}
struct EditorCx<'a> {
bp: &'a egui::Painter,
layer: &'a Layer,
ectx: &'a EvalCtx<'a>,
}
/// An effect's custom editor (curves, levels, roto...), or the histogram-only fallback.
fn effect_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &EditorCx, g: &PropGroup, er: Rect, actions: &mut Actions) {
let GroupKind::Effect { effect } = &g.kind else { return };
match effect.as_str() {
"ec.color.curves" => curves_editor(app, ui, cx.bp, cx.layer, g, cx.ectx, er, actions),
effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, cx.bp, cx.layer, g, er, actions),
effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor(app, ui, cx.bp, cx.layer, g, er, actions),
effectcraft_engine::effects::roto::ID => roto_editor(app, ui, cx.bp, cx.layer, g, er, actions),
effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, cx.bp, cx.layer, g, er, actions),
"ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions),
crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => {
crate::panels::fx_editors::header_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions)
}
_ => histogram_only(app, ui, cx.bp, g, er),
}
}
struct ReorderDrag<'a> {
tops: &'a [(u64, f32)],
y: f32,
du: u64,
drag_id: egui::Id,
layer_id: u64,
}
/// Drag an effect header to reorder: the insertion line between headers, and the drop.
fn reorder_drag(ctx: &egui::Context, bp: &egui::Painter, body: Rect, t: &Tokens, st: &ReorderDrag, actions: &mut Actions) {
let ReorderDrag { tops, y, du, drag_id, layer_id } = *st;
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, "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));
}
}
/// Drop effects and animation presets here.
fn drop_payload(ui: &egui::Ui, rect: Rect, p: &egui::Painter, t: &Tokens, layer_id: u64, actions: &mut Actions) {
let ctx = ui.ctx().clone();
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]}))),
crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer_id)),
_ => 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);
}
}
}
fn about_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, bp: &egui::Painter, t: &Tokens, effect: &str, g: &PropGroup, r: Rect) {
// 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);
}
}
/// 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
@@ -99,32 +233,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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));
}
let hdr_cx = EffectHdr { g, shown: &shown, r, tw, reset, rename_id, layer_id: layer.id.0 };
if let Some(value) = editing.as_ref().cloned() {
rename_field(app, ui, &hdr_cx, value, &mut actions);
} 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);
@@ -135,23 +246,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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);
}
about_button(app, ui, &bp, &t, effect, g, r);
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)),
@@ -234,18 +329,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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),
}
effect_editor(app, ui, &EditorCx { bp: &bp, layer: &layer, ectx: &ectx }, g, er, &mut actions);
}
}
let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() };
@@ -264,25 +348,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
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));
}
reorder_drag(&ctx, &bp, body, &t, &ReorderDrag { tops: &tops, y, du, drag_id, layer_id: layer.id.0 }, &mut actions);
}
// 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.
@@ -297,21 +364,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
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);
}
}
drop_payload(ui, rect, &p, &t, layer.id.0, &mut actions);
if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
app.ui.viewer.property_interacting = true;
ctx.request_repaint();
+356 -256
View File
@@ -1,27 +1,27 @@
use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key};
use super::*;
use super::{
Align2, Color32, EffectcraftApp, EvalCtx, FxPick, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, icons, json,
pos2, vec2, widgets,
};
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));
}
/// The layer and whether the time is on a key, for the key navigator.
#[derive(Clone, Copy)]
struct KeyNav {
layer_id: u64,
on_key: bool,
}
/// ◀ ◆ ▶ 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,
) {
fn key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, nav: KeyNav, prop: &Property, r: Rect, actions: &mut Actions) {
let KeyNav { layer_id, on_key } = nav;
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());
@@ -48,7 +48,7 @@ fn key_navigator(
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}))),
_ => actions.push(("prop.toggleKey".into(), json!({"layer": layer_id, "prop": uid, "selected": true}))),
}
}
}
@@ -71,6 +71,44 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p
}
}
/// What the value controls of one property row share.
#[derive(Clone, Copy)]
struct PropCx<'a> {
p: &'a egui::Painter,
layer: &'a Layer,
group: &'a PropGroup,
prop: &'a Property,
r: Rect,
vx: f32,
cy: f32,
swr: Rect,
t: Tokens,
uid: u64,
scrub: bool,
}
impl PropCx<'_> {
fn set(&self, actions: &mut Actions, v: serde_json::Value) {
let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub);
actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub})))
}
fn twirl_open(&self, app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let PropCx { swr, cy, uid, prop, t, .. } = self;
let (cy, uid) = (*cy, *uid);
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);
}
}
/// 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)]
@@ -115,7 +153,9 @@ fn prop_row(
}
}
if prop.is_animated() {
key_navigator(app, ui, p, layer, prop, ectx, r, actions);
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);
key_navigator(app, ui, p, KeyNav { layer_id: layer.id.0, on_key }, prop, r, actions);
}
let value = ectx.value(layer, prop);
// Keep a shared value column while names and their icons follow the group hierarchy.
@@ -150,260 +190,320 @@ fn prop_row(
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);
};
let cx = PropCx { p, layer, group, prop, r, vx, cy, swr, t, uid, scrub };
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})));
}
}
}
}
Value::Scalar(_) if matches!(prop.ui, ParamUi::Mask) => value_mask(app, ui, &cx, &value, actions),
Value::Scalar(_) if matches!(prop.ui, ParamUi::Angle) => value_angle(app, ui, &cx, &value, actions),
Value::Scalar(_) => value_scalar(app, ui, &cx, &value, actions),
Value::Vec2(_) | Value::Vec3(_) => dims = value_vector(app, ui, &cx, &value, actions),
Value::Color(_) => value_color(app, ui, &cx, &value, actions),
Value::Bool(_) => value_toggle(app, ui, &cx, &value, actions),
Value::Enum(_) => value_popup(app, ui, &cx, &value, actions),
Value::Layer(_) => value_layer_ref(app, ui, &cx, &value, actions),
// 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);
}
Value::Str(_) if crate::panels::fx_editors::is_extractor_channel(group, prop) => value_extractor(app, ui, &cx, &value, 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);
}
Value::Str(_) if matches!(prop.ui, ParamUi::File { .. }) => value_file(app, ui, &cx, &value, actions),
// 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);
}
Value::Str(_) if matches!(prop.ui, ParamUi::Text) => value_text(app, ui, &cx, &value, actions),
Value::Gradient(_) => value_gradient(app, &cx, &value),
_ => {}
}
dims
}
fn value_mask(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Scalar(v) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx;
// 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)) {
cx.set(actions, json!(ni as f64));
}
}
fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Scalar(v) = value else {
return;
};
let PropCx { t, uid, cy, vx, prop, .. } = *cx;
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 {
cx.set(actions, json!(nv));
}
cx.twirl_open(app, ui);
}
fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Scalar(v) = value else {
return;
};
let PropCx { t, uid, cy, vx, prop, .. } = *cx;
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,
&widgets::HotSpec { speed, range: (min, max), decimals: 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 {
cx.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 { .. }) {
cx.twirl_open(app, ui);
}
}
fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) -> Vec<Rect> {
let mut dims = vec![];
let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx;
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],
&widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" },
&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;
cx.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;
}
dims
}
fn value_color(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Color(c) = value else {
return;
};
let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx;
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) {
cx.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
}
}
fn value_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Bool(b) = value else {
return;
};
let PropCx { t, uid, cy, vx, p, prop, .. } = *cx;
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() {
cx.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);
}
fn value_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Enum(i) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx;
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)) {
cx.set(actions, json!(ni));
}
}
}
fn value_layer_ref(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Layer(l) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, layer, prop, group, .. } = *cx;
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))) {
cx.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})));
}
}
}
}
fn value_extractor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Str(s) = value else {
return;
};
let PropCx { cy, vx, r, layer, prop, .. } = *cx;
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);
}
fn value_file(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Str(s) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, p, layer, prop, .. } = *cx;
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);
}
app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown);
}
fn value_text(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) {
let Value::Str(s) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx;
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.to_owned());
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));
}
}
fn value_gradient(app: &mut EffectcraftApp, cx: &PropCx, value: &Value) {
let Value::Gradient(g) = value else {
return;
};
let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx;
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);
}
/// 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();
@@ -1,6 +1,10 @@
use std::ops::ControlFlow;
use super::common::{Actions, effect_active, gesture_key};
use super::rows::FxScope;
use super::*;
use super::{
Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Layer, Mat3, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, fw, json, vec2,
};
// ---------------------------------------------------------------------------------------------
// Viewer hook
@@ -22,6 +26,156 @@ fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx
}
}
/// An armed crosshair / eyedropper pick: the hint chip, the cursor preview, and the click that
/// sets or samples. `Break` when the picked layer is gone; `Continue` carries the point preview.
fn fx_pick_session(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &crate::panels::viewer::ViewerMap,
ectx: &EvalCtx,
l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3,
actions: &mut Actions,
) -> ControlFlow<(), Option<egui::Pos2>> {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut point_preview = None;
let Some(pick) = app.ui.fx_pick.clone() else {
return ControlFlow::Continue(None);
};
let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else {
app.ui.fx_pick = None;
return ControlFlow::Break(());
};
// 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();
}
ControlFlow::Continue(point_preview)
}
/// An armed text-colour pick: sample the shown frame on click, Esc cancels.
fn text_color_pick(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
map: &crate::panels::viewer::ViewerMap,
ectx: &EvalCtx,
actions: &mut Actions,
) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let Some(pick) = app.ui.text_color_pick.clone() else {
return;
};
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();
}
}
}
/// Effect Controls' part of the Composition viewer, called by the viewer after its own
/// interaction (so these controls sit on top):
///
@@ -42,124 +196,11 @@ pub fn viewer_hook(
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 point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) {
ControlFlow::Break(()) => return,
ControlFlow::Continue(p) => p,
};
text_color_pick(app, ui, painter, map, ectx, &mut actions);
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() {
+31 -24
View File
@@ -264,32 +264,13 @@ fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec<Pre
v
}
/// The Effects & Presets panel: search, a contents menu (colour-depth and GPU filters, whether
/// presets show, categories or one alphabetical list, refresh and clear of the recently used
/// list), then Favourites, Recently Used, Animation Presets and the effects by category or
/// alphabetically, filtered by the query. Picking an effect or preset applies it to the selection
/// (`effect.apply` or the preset's own command); applying an effect also records it as recently
/// used and opens Effect Controls. Without a selected layer it only reports that in
/// `app.ui.status`.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().clone();
// Track the last applied effect (menus, drag and drop, the control channel) as recently used.
if let Some(last) = app.session.state.last_effect.clone()
&& app.ui.effects_recent.first() != Some(&last)
{
push_recent(&mut app.ui.effects_recent, &last);
}
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
let mut q = app.ui.effects_search.clone();
widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
app.ui.effects_search = q.clone();
app.auto.add("effects.search", sr, "Search");
/// The "Effects & Presets options" hamburger menu beside the search field; returns whether
/// "Refresh List" was picked.
fn contents_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, rect: Rect, sr: Rect, t: &Tokens) -> bool {
// Contents menu.
let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
let mpop = egui::Id::unique("fx-contents-menu");
if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
if widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
widgets::open_popup(ui, mpop);
}
app.auto.add("effects.menu", mr, "Effects & Presets options");
@@ -339,7 +320,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let v = &mut app.ui.effects_view;
match i {
i if menu.get(i) == Some(&copy_label) => {
if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
if let Err(e) = crate::menus::invoke(app, ctx, "effects.copyPresetsFolderPath", json!({})) {
app.ui.status = e;
}
}
@@ -354,6 +335,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
_ => {}
}
}
force_scan
}
/// The Effects & Presets panel: search, a contents menu (colour-depth and GPU filters, whether
/// presets show, categories or one alphabetical list, refresh and clear of the recently used
/// list), then Favourites, Recently Used, Animation Presets and the effects by category or
/// alphabetically, filtered by the query. Picking an effect or preset applies it to the selection
/// (`effect.apply` or the preset's own command); applying an effect also records it as recently
/// used and opens Effect Controls. Without a selected layer it only reports that in
/// `app.ui.status`.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let p = ui.painter().clone();
// Track the last applied effect (menus, drag and drop, the control channel) as recently used.
if let Some(last) = app.session.state.last_effect.clone()
&& app.ui.effects_recent.first() != Some(&last)
{
push_recent(&mut app.ui.effects_recent, &last);
}
let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
let mut q = app.ui.effects_search.clone();
widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
app.ui.effects_search = q.clone();
app.auto.add("effects.search", sr, "Search");
let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t);
let view = app.ui.effects_view.clone();
let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
let lp = p.with_clip_rect(list);
+9 -1
View File
@@ -331,8 +331,16 @@ fn layout(text: &str, font: FontId, base: Color32, sx: &Syntax, p: &Scripting, b
job
}
/// The scripting preferences, text colour and row count an [`editor`] is drawn with.
pub struct EditorOpts<'a> {
pub prefs: &'a Scripting,
pub color: Color32,
pub rows: usize,
}
/// The editor: returns the response; `buf` is edited in place.
pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: &Scripting, color: Color32, rows: usize, t: &Tokens) -> egui::Response {
pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, opts: EditorOpts, t: &Tokens) -> egui::Response {
let EditorOpts { prefs: p, color, rows } = opts;
let size = (p.editor_font_size.clamp(8, 40) as f32) * 0.9;
let font = Tokens::mono(size);
// Line numbers in a gutter on the left.
+280 -264
View File
@@ -206,6 +206,280 @@ fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option<V
})
}
/// The New Composition from Selection dialog's title and fields.
fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec<Field>) {
let names: Vec<(String, Value)> =
s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
(
"New Composition from Selection".into(),
vec![
Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
Field::num("duration", "Still Duration (seconds)", still),
Field::bool("addToRenderQueue", "Add to Render Queue", false),
Field::bool("sequence", "Sequence Layers", false),
Field::bool("overlap", "Overlap", false),
Field::num("overlapDuration", "Duration (seconds)", 1.0),
Field::choice(
"transition",
"Transition",
&[("Off", json!("off")), ("Dissolve Front Layer", json!("dissolveFront")), ("Cross Dissolve Front and Back Layers", json!("crossDissolve"))],
0,
),
],
)
}
/// The Interpret Footage dialog for the selected footage (or its proxy), if there is one.
fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec<Field>)> {
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
(_, Some(px)) if proxy => Some(px.footage.clone()),
(effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
_ => None,
});
let f = f?;
let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
let mut fields = vec![
// Main Options ▸ Alpha.
Field::choice(
"alpha",
"Alpha",
&[
("Interpret Straight - Unmatted", json!("straight")),
("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
("Ignore", json!("ignore")),
("Guess", json!("guess")),
],
f.alpha as usize,
),
Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
// Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
Field::choice(
"fields",
"Separate Fields",
&[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
f.fields as usize,
),
Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
Field::num("loop", "Loop (times)", f.loop_count as f64),
// Color.
Field::choice(
"colorProfile",
"Assign Profile",
&[
("Embedded / sRGB", json!("auto")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("ACEScg", json!("acescg")),
("ACES2065-1", json!("aces2065")),
("Rec. 2100 PQ", json!("rec2100pq")),
("Rec. 2100 HLG", json!("rec2100hlg")),
],
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
];
// After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
// After Effects), then Start Timecode.
let mut timing = Vec::new();
if f.kind == effectcraft_engine::project::FootageKind::Sequence {
let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
_ if f.alphabetical => "files in alphabetical order".into(),
Some(gaps) => format!("{gaps} shown as color bars"),
None => "no missing frames".into(),
};
timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
}
if f.kind != effectcraft_engine::project::FootageKind::Still {
timing.push(Field::choice(
"overrideStart",
"Start Timecode",
&[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
usize::from(f.start_timecode.is_some()),
));
timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
}
let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
fields.splice(at..at, timing);
Some((if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields))
}
/// The Project Settings dialog's title and fields.
fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec<Field>) {
let st = &s.project.settings;
// The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
// tr_args: wrapping the assembled string in tr() would look up the backend's name.
let gpu_label = match &s.accel {
Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
};
let depth = match st.bit_depth.label() {
l if l.starts_with("16") => 1,
l if l.starts_with("32") => 2,
_ => 0,
};
(
"Project Settings".into(),
vec![
Field::choice(
"bitDepth",
"Depth",
&[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
depth,
),
Field::choice(
"timeDisplay",
"Time display style",
&[
("Timecode", json!("timecode")),
("Frames", json!("frames")),
("Feet + Frames (35mm)", json!("feet35")),
("Feet + Frames (16mm)", json!("feet16")),
],
{
use effectcraft_engine::project::TimeDisplayStyle as T;
match st.time_display {
T::Timecode => 0,
T::Frames => 1,
T::Feet35 => 2,
T::Feet16 => 3,
}
},
),
// Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
Field::choice(
"colorEngine",
"Color engine",
&[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
),
Field::choice(
"workingSpace",
"Working space",
&[
("None", json!("none")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("ACEScg", json!("acescg")),
("ACES2065-1", json!("aces2065")),
],
st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
),
// With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
Field::bool("linearize", "Linearize working space", st.linearize),
Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
Field::choice(
"hdr",
"HDR on SDR displays and outputs",
&[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
st.hdr as usize,
),
Field::choice(
"outputSpace",
"Output color space",
&[
("sRGB IEC61966-2.1 (default)", json!("default")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
],
{
use effectcraft_engine::project::ColorSpace as C;
match st.output_space {
Some(C::Rec709) => 1,
Some(C::Rec2020) => 2,
Some(C::DisplayP3) => 3,
Some(C::Rec2100Pq) => 4,
Some(C::Rec2100Hlg) => 5,
_ => 0,
}
},
),
// Video Rendering and Effects ▸ Use.
Field::choice(
"renderer",
"Video rendering and effects",
&[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
usize::from(!st.gpu_acceleration),
),
],
)
}
/// The Export Adobe Premiere Pro Project dialog's title and fields.
fn export_timeline_form(s: &effectcraft_engine::Session) -> (String, Vec<Field>) {
let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
(
"Export Adobe Premiere Pro Project".into(),
vec![
Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
Field::save_path("path", "File", &path),
Field::choice(
"format",
"Format",
&[
("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
("FCPXML (.fcpxml)", json!("fcpxml")),
("OpenTimelineIO (.otio)", json!("otio")),
("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
("AAF (.aaf)", json!("aaf")),
("OMF (.omf, audio)", json!("omf")),
],
0,
),
Field::choice(
"prerender",
"Pre-render (ProRes 4444)",
&[
("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
("All layers", json!("all")),
("None (leave them out)", json!("none")),
],
0,
),
Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
],
)
}
/// The Layer ▸ Transform value dialog for the property named by `p` (default Position).
fn transform_form(p: &Value, layer: Option<&effectcraft_engine::project::Layer>, lt: effectcraft_engine::time::Tick) -> (String, Vec<Field>) {
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
let three = layer.is_some_and(|l| l.is_3d());
let title = match prop {
"anchor" => "Anchor Point",
"position" => "Position",
"scale" => "Scale",
"orientation" => "Orientation",
"rotation" => "Rotation",
_ => "Opacity",
};
let fields = match prop {
"rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
"orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
};
(title.into(), fields)
}
/// If `id` is a dialog command invoked without its parameters, open its form and return true.
pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let s = &app.session;
@@ -281,27 +555,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false };
(pr.name.clone(), fields)
}
"layer.setTransform" if !has(p, &["value"]) => {
let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
let three = layer.is_some_and(|l| l.is_3d());
let title = match prop {
"anchor" => "Anchor Point",
"position" => "Position",
"scale" => "Scale",
"orientation" => "Orientation",
"rotation" => "Rotation",
_ => "Opacity",
};
let fields = match prop {
"rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
"orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
_ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
};
(title.into(), fields)
}
"layer.setTransform" if !has(p, &["value"]) => transform_form(p, layer, lt),
"path.freeTransform" if !has(p, &["scale", "rotation", "offset"]) => (
"Free Transform Points".into(),
vec![
@@ -377,32 +631,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
"file.newCompFromSelection"
if s.state.project_selection.len() > 1 && !has(p, &["single", "dimensionsFrom", "duration", "sequence", "addToRenderQueue"]) =>
{
let names: Vec<(String, Value)> =
s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
(
"New Composition from Selection".into(),
vec![
Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
Field::num("duration", "Still Duration (seconds)", still),
Field::bool("addToRenderQueue", "Add to Render Queue", false),
Field::bool("sequence", "Sequence Layers", false),
Field::bool("overlap", "Overlap", false),
Field::num("overlapDuration", "Duration (seconds)", 1.0),
Field::choice(
"transition",
"Transition",
&[
("Off", json!("off")),
("Dissolve Front Layer", json!("dissolveFront")),
("Cross Dissolve Front and Back Layers", json!("crossDissolve")),
],
0,
),
],
)
new_comp_from_selection_form(s)
}
"layer.sequence" if !has(p, &["overlap"]) => (
"Sequence Layers".into(),
@@ -425,188 +654,10 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) =>
{
let proxy = id == "file.interpretProxy";
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
(_, Some(px)) if proxy => Some(px.footage.clone()),
(effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
_ => None,
});
let Some(f) = f else { return false };
let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
let mut fields = vec![
// Main Options ▸ Alpha.
Field::choice(
"alpha",
"Alpha",
&[
("Interpret Straight - Unmatted", json!("straight")),
("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
("Ignore", json!("ignore")),
("Guess", json!("guess")),
],
f.alpha as usize,
),
Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
// Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
Field::choice(
"fields",
"Separate Fields",
&[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
f.fields as usize,
),
Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
Field::num("loop", "Loop (times)", f.loop_count as f64),
// Color.
Field::choice(
"colorProfile",
"Assign Profile",
&[
("Embedded / sRGB", json!("auto")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("ACEScg", json!("acescg")),
("ACES2065-1", json!("aces2065")),
("Rec. 2100 PQ", json!("rec2100pq")),
("Rec. 2100 HLG", json!("rec2100hlg")),
],
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
];
// After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
// After Effects), then Start Timecode.
let mut timing = Vec::new();
if f.kind == effectcraft_engine::project::FootageKind::Sequence {
let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
_ if f.alphabetical => "files in alphabetical order".into(),
Some(gaps) => format!("{gaps} shown as color bars"),
None => "no missing frames".into(),
};
timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
}
if f.kind != effectcraft_engine::project::FootageKind::Still {
timing.push(Field::choice(
"overrideStart",
"Start Timecode",
&[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
usize::from(f.start_timecode.is_some()),
));
timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
}
let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
fields.splice(at..at, timing);
(if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields)
}
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
let st = &s.project.settings;
// The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
// tr_args: wrapping the assembled string in tr() would look up the backend's name.
let gpu_label = match &s.accel {
Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
};
let depth = match st.bit_depth.label() {
l if l.starts_with("16") => 1,
l if l.starts_with("32") => 2,
_ => 0,
};
(
"Project Settings".into(),
vec![
Field::choice(
"bitDepth",
"Depth",
&[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
depth,
),
Field::choice(
"timeDisplay",
"Time display style",
&[
("Timecode", json!("timecode")),
("Frames", json!("frames")),
("Feet + Frames (35mm)", json!("feet35")),
("Feet + Frames (16mm)", json!("feet16")),
],
{
use effectcraft_engine::project::TimeDisplayStyle as T;
match st.time_display {
T::Timecode => 0,
T::Frames => 1,
T::Feet35 => 2,
T::Feet16 => 3,
}
},
),
// Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
Field::choice(
"colorEngine",
"Color engine",
&[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
),
Field::choice(
"workingSpace",
"Working space",
&[
("None", json!("none")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("ACEScg", json!("acescg")),
("ACES2065-1", json!("aces2065")),
],
st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
),
// With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
Field::bool("linearize", "Linearize working space", st.linearize),
Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
Field::choice(
"hdr",
"HDR on SDR displays and outputs",
&[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
st.hdr as usize,
),
Field::choice(
"outputSpace",
"Output color space",
&[
("sRGB IEC61966-2.1 (default)", json!("default")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
],
{
use effectcraft_engine::project::ColorSpace as C;
match st.output_space {
Some(C::Rec709) => 1,
Some(C::Rec2020) => 2,
Some(C::DisplayP3) => 3,
Some(C::Rec2100Pq) => 4,
Some(C::Rec2100Hlg) => 5,
_ => 0,
}
},
),
// Video Rendering and Effects ▸ Use.
Field::choice(
"renderer",
"Video rendering and effects",
&[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
usize::from(!st.gpu_acceleration),
),
],
)
let Some(form) = interpret_footage_form(s, proxy) else { return false };
form
}
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s),
"layer.autoTrace" if !has(p, &["channel", "timeSpan", "threshold"]) => (
"Auto-trace".into(),
vec![
@@ -662,42 +713,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
)
}
// File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL).
"file.exportTimeline" if !has(p, &["path"]) => {
let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
(
"Export Adobe Premiere Pro Project".into(),
vec![
Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
Field::save_path("path", "File", &path),
Field::choice(
"format",
"Format",
&[
("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
("FCPXML (.fcpxml)", json!("fcpxml")),
("OpenTimelineIO (.otio)", json!("otio")),
("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
("AAF (.aaf)", json!("aaf")),
("OMF (.omf, audio)", json!("omf")),
],
0,
),
Field::choice(
"prerender",
"Pre-render (ProRes 4444)",
&[
("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
("All layers", json!("all")),
("None (leave them out)", json!("none")),
],
0,
),
Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
],
)
}
"file.exportTimeline" if !has(p, &["path"]) => export_timeline_form(s),
"file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
"New Placeholder".into(),
vec![
+5 -15
View File
@@ -66,19 +66,9 @@ fn register_points(app: &mut EffectcraftApp, prefix: &str, gr: Rect, pts: &[[f32
}
/// Small coloured tabs (`names[i]`, swatch `cols[i]`); returns the clicked one.
fn tabs(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
x0: f32,
cy: f32,
cur: usize,
names: &[&str],
cols: &[Color32],
id: &str,
) -> Option<usize> {
fn tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: Pos2, cur: usize, (names, cols): (&[&str], &[Color32]), id: &str) -> Option<usize> {
let t = app.tokens;
let mut x = x0;
let (mut x, cy) = (at.x, at.y);
let mut out = None;
for (i, name) in names.iter().enumerate() {
let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 };
@@ -188,7 +178,7 @@ fn rgb_curves(
let cy = r.min.y + 15.0;
let cols = [Color32::from_gray(0xe8), Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
let names: Vec<&str> = RGB_CURVES.iter().map(|c| c.1).collect();
if let Some(n) = tabs(app, ui, p, x0, cy, ch, &names, &cols, &format!("{prefix}.channel")) {
if let Some(n) = tabs(app, ui, p, pos2(x0, cy), ch, (&names, &cols), &format!("{prefix}.channel")) {
app.ui.fx_curve_channel.insert(g.uid, n);
}
let id = RGB_CURVES[ch].0;
@@ -299,7 +289,7 @@ fn huesat_curves(
let x0 = r.min.x + 38.0;
let cy = r.min.y + 15.0;
let names: Vec<&str> = HUESAT.iter().map(|c| c.1).collect();
if let Some(n) = tabs(app, ui, p, x0, cy, k, &names, &[], &format!("{prefix}.curve")) {
if let Some(n) = tabs(app, ui, p, pos2(x0, cy), k, (&names, &[]), &format!("{prefix}.curve")) {
app.ui.fx_curve_channel.insert(g.uid, n);
}
let (id, name, axis, periodic) = HUESAT[k];
@@ -724,7 +714,7 @@ fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, laye
}
let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(2);
let cols = [Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
if let Some(n) = tabs(app, ui, p, x0, r.min.y + 15.0, ch, &["Red", "Green", "Blue"], &cols, &format!("{prefix}.channel")) {
if let Some(n) = tabs(app, ui, p, pos2(x0, r.min.y + 15.0), ch, (&["Red", "Green", "Blue"], &cols), &format!("{prefix}.channel")) {
app.ui.fx_curve_channel.insert(euid, n);
}
if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, r.min.y + 6.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) {
+2 -1
View File
@@ -59,7 +59,8 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals:
let (rev, deg) = split_angle(v);
let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t);
let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text);
let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t);
let (dr, new_deg, _) =
widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), &widgets::HotSpec { speed: 0.5, range: (-1e9, 1e9), decimals, suffix: "°" }, t);
let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id);
let out = match (new_rev, new_deg) {
(Some(r), _) => Some(r as f64 * 360.0 + deg),
+168 -107
View File
@@ -240,17 +240,16 @@ impl ButtonBar {
/// at `y`: draws it, registers it for automation and advances the bar. Once a button does not fit
/// (or a previous one overflowed), the button goes to the "»" menu instead and only the id the
/// menu picked reports a click. Returns whether this button was chosen.
fn text_button(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
p: &egui::Painter,
bar: &mut ButtonBar,
y: f32,
label: &str,
/// One bar button for [`text_button`]: label, lit state, automation id and tooltip.
struct BarBtn<'a> {
label: &'a str,
on: bool,
id: &str,
tip: &str,
) -> bool {
id: &'a str,
tip: &'a str,
}
fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool {
let BarBtn { label, on, id, tip } = btn;
let t = app.tokens;
let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0;
// Once one button overflows, the rest follow it into the menu (they keep their order).
@@ -277,35 +276,44 @@ fn text_button(
resp.clicked()
}
/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) {
let t = app.tokens;
let ctx = ui.ctx().clone();
let footer_h = 28.0;
let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
// Background first so keys and handles (added later) sit on top for hit-testing.
let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
let speed = speed_graph(app, comp);
let cs = curves(app, comp, ectx, tm, plot, speed);
// Just opened, or the graph type changed (value and speed have different units): frame the
// graphs (Fit Selection to View, else Fit All Graphs to View).
let was_speed_id = egui::Id::unique("graph-was-speed");
let type_changed = ctx.data(|d| d.get_temp::<bool>(was_speed_id)).is_some_and(|was| was != speed);
ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
if ctx.data_mut(|d| d.remove_temp::<bool>(fit_pending_id())).unwrap_or(false) || type_changed {
if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
fit_selection(app, &cs, tm, plot, speed);
} else {
fit_all(app);
/// What the Graph Editor draws: the composition, its evaluation context and the time mapping.
#[derive(Clone, Copy)]
pub(crate) struct GraphView<'a> {
pub comp: &'a Comp,
pub ectx: &'a EvalCtx<'a>,
pub tm: TMap,
}
/// Show Reference Graph: the other graph type, faint, scaled to the plot.
fn reference_graph(app: &EffectcraftApp, view: GraphView, plot: Rect, speed: bool, pc: &egui::Painter) {
let GraphView { comp, ectx, tm } = view;
let other = curves(app, comp, ectx, tm, plot, !speed);
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &other {
for (_, v) in &c.pts {
rlo = rlo.min(*v);
rhi = rhi.max(*v);
}
}
if rlo.is_finite() {
let span = (rhi - rlo).max(1e-9);
let lines: Vec<(Color32, Vec<Pos2>)> = other
.iter()
.map(|c| {
(
c.color,
c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05)).collect(),
)
})
.collect();
super::graph_tools::reference_graph(pc, plot, &lines);
}
}
// Vertical range: auto-zoom to the curves + handles, or the stored manual range.
/// The value range that fits every curve point and key handle (with a margin).
fn auto_range(cs: &[Curve], speed: bool) -> (f64, f64) {
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &cs {
for c in cs {
for (_, v) in &c.pts {
lo = lo.min(*v);
hi = hi.max(*v);
@@ -327,7 +335,78 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
hi = lo + 1.0;
}
let pad = (hi - lo) * 0.08;
let auto = (lo - pad, hi + pad);
(lo - pad, hi + pad)
}
/// Background: a click deselects keys, a drag box-selects the keys inside it.
fn box_select(ui: &egui::Ui, pc: &egui::Painter, bg: &egui::Response, key_screens: &[(serde_json::Value, Pos2)], accent: Color32, actions: &mut Actions) {
let ctx = ui.ctx().clone();
if bg.clicked() {
actions.push(("keys.select".into(), json!({"keys": []})));
}
if bg.dragged()
&& let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
{
let br = Rect::from_two_pos(o, cur);
pc.rect_filled(br, 0.0, accent.gamma_multiply(0.12));
pc.rect_stroke(br, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle);
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
}
if bg.drag_stopped()
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("graph-box")))
{
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("graph-box")));
let keys: Vec<serde_json::Value> = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
}
}
/// Wheel over the graph with Ctrl zooms the value axis `(lo, hi)` (turns auto-zoom off).
fn wheel_zoom(app: &mut EffectcraftApp, ui: &egui::Ui, plot: Rect, (lo, hi): (f64, f64)) {
if ui.rect_contains_pointer(plot) {
let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
if zoom && dy.abs() > 0.0 {
let k = (-dy as f64 / 200.0).exp();
let mid = (lo + hi) / 2.0;
app.ui.timeline.graph_auto_zoom = false;
app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
}
}
}
/// Just opened, or the graph type changed (value and speed have different units): frame the
/// graphs (Fit Selection to View, else Fit All Graphs to View).
fn fit_on_open(app: &mut EffectcraftApp, ctx: &egui::Context, cs: &[Curve], tm: TMap, plot: Rect, speed: bool) {
let was_speed_id = egui::Id::unique("graph-was-speed");
let type_changed = ctx.data(|d| d.get_temp::<bool>(was_speed_id)).is_some_and(|was| was != speed);
ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
if ctx.data_mut(|d| d.remove_temp::<bool>(fit_pending_id())).unwrap_or(false) || type_changed {
if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
fit_selection(app, cs, tm, plot, speed);
} else {
fit_all(app);
}
}
}
/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, view: GraphView, area: Rect, actions: &mut Actions) {
let GraphView { comp, ectx, tm } = view;
let t = app.tokens;
let ctx = ui.ctx().clone();
let footer_h = 28.0;
let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
// Background first so keys and handles (added later) sit on top for hit-testing.
let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
let speed = speed_graph(app, comp);
let cs = curves(app, comp, ectx, tm, plot, speed);
fit_on_open(app, &ctx, &cs, tm, plot, speed);
// Vertical range: auto-zoom to the curves + handles, or the stored manual range.
let auto = auto_range(&cs, speed);
// While a key or handle is being dragged the scale stays put (no feedback loop).
let drag_id = egui::Id::unique("graph-dragging");
let dragging = ctx.data(|d| d.get_temp::<bool>(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
@@ -358,32 +437,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
);
}
// Show Reference Graph: the other graph type, faint, scaled to the plot.
if app.ui.timeline.graph_reference && !cs.is_empty() {
let other = curves(app, comp, ectx, tm, plot, !speed);
let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
for c in &other {
for (_, v) in &c.pts {
rlo = rlo.min(*v);
rhi = rhi.max(*v);
}
}
if rlo.is_finite() {
let span = (rhi - rlo).max(1e-9);
let lines: Vec<(Color32, Vec<Pos2>)> = other
.iter()
.map(|c| {
(
c.color,
c.pts
.iter()
.map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
.collect(),
)
})
.collect();
super::graph_tools::reference_graph(&pc, plot, &lines);
}
reference_graph(app, view, plot, speed, &pc);
}
// Curves.
for c in &cs {
@@ -523,37 +578,19 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
// Background: click deselects keys, drag box-selects.
if bg.clicked() {
actions.push(("keys.select".into(), json!({"keys": []})));
}
if bg.dragged()
&& let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
{
let br = Rect::from_two_pos(o, cur);
pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
}
if bg.drag_stopped()
&& let Some(br) = ctx.data(|d| d.get_temp::<Rect>(egui::Id::unique("graph-box")))
{
ctx.data_mut(|d| d.remove::<Rect>(egui::Id::unique("graph-box")));
let keys: Vec<serde_json::Value> = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
}
// Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off).
if ui.rect_contains_pointer(plot) {
let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
if zoom && dy.abs() > 0.0 {
let k = (-dy as f64 / 200.0).exp();
let mid = (lo + hi) / 2.0;
app.ui.timeline.graph_auto_zoom = false;
app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
}
box_select(ui, &pc, &bg, &key_screens, t.accent, actions);
wheel_zoom(app, ui, plot, (lo, hi));
if button_bar(app, ui, p, comp, bar, speed, actions) {
fit_selection(app, &cs, tm, plot, speed);
}
}
// Button bar.
/// The footer button bar: graph type, display toggles, fit, snap and keyframe interpolation.
/// Returns whether Fit Selection was pressed.
fn button_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, bar: Rect, speed: bool, actions: &mut Actions) -> bool {
let t = app.tokens;
let ctx = ui.ctx().clone();
let mut fit_sel = false;
p.rect_filled(bar, 0.0, t.panel_bg);
p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
let y = bar.center().y;
@@ -566,48 +603,71 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
p,
&mut buttons,
y,
"Auto",
auto_type,
"autoSelectGraphType",
"Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
BarBtn {
label: "Auto",
on: auto_type,
id: "autoSelectGraphType",
tip: "Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
},
) {
app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
}
// Choosing a graph type turns Auto-Select off, as in After Effects.
if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Value", on: !speed, id: "valueGraph", tip: "Edit Value Graph" }) {
app.ui.timeline.graph_mode = "value".into();
}
if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Speed", on: speed, id: "speedGraph", tip: "Edit Speed Graph" }) {
app.ui.timeline.graph_mode = "speed".into();
}
buttons.x += 6.0;
if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
if text_button(
app,
ui,
p,
&mut buttons,
y,
BarBtn { label: "Selected", on: show_sel, id: "showSelected", tip: "Show Selected Properties (else all animated properties)" },
) {
app.ui.timeline.graph_show_selected = !show_sel;
}
if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Auto-zoom", on: auto_on, id: "autoZoom", tip: "Auto-zoom graph height" }) {
app.ui.timeline.graph_auto_zoom = !auto_on;
}
if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") {
if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit", on: false, id: "fit", tip: "Fit all graphs to view" }) {
fit_all(app);
}
if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
fit_selection(app, &cs, tm, plot, speed);
if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit Selection", on: false, id: "fitSelection", tip: "Fit selection to view" }) {
fit_sel = true;
}
buttons.x += 6.0;
let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Snap", on: snap, id: "snap", tip: "Snap (key drags snap to the current time and other keys)" })
{
app.ui.timeline.graph_snap = !snap;
}
if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") {
if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Reference", on: reference, id: "reference", tip: "Show Reference Graph" }) {
app.ui.timeline.graph_reference = !reference;
}
if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
if text_button(
app,
ui,
p,
&mut buttons,
y,
BarBtn { label: "Transform Box", on: tbox, id: "transformBox", tip: "Show Transform Box when multiple keys are selected" },
) {
app.ui.timeline.graph_transform_box = !tbox;
}
buttons.x += 6.0;
let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
&& let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
if text_button(
app,
ui,
p,
&mut buttons,
y,
BarBtn { label: "Separate Dimensions", on: false, id: "separateDimensions", tip: "Separate Dimensions (Position)" },
) && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
{
actions.push(("prop.separateDimensions".into(), json!({"layer": l})));
}
@@ -621,11 +681,12 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"),
] {
let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or("");
if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) {
if text_button(app, ui, p, &mut buttons, y, BarBtn { label, on: false, id: &id, tip }) {
actions.push((cmd.into(), params));
}
}
buttons.more(app, ui, p, bar);
fit_sel
}
/// Fit All Graphs to View: the whole comp in time, auto-zoomed values.
+14 -6
View File
@@ -125,8 +125,16 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str
(ok, cancel)
}
fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive<f64>, suffix: &str, enabled: bool) {
let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)));
/// Drag speed, clamp range and suffix of a [`drag`] field.
struct DragSpec<'a> {
speed: f64,
range: std::ops::RangeInclusive<f64>,
suffix: &'a str,
}
fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, spec: DragSpec, enabled: bool) {
let r =
ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(spec.speed).range(spec.range).max_decimals(2).suffix(spec.suffix)));
app.auto.add(id, r.rect, id);
}
@@ -147,13 +155,13 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.horizontal(|ui| {
ui.label(tr("Speed:"));
let v = if out { &mut d.out_speed[dim] } else { &mut d.in_speed[dim] };
drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, 1.0, -1e7..=1e7, "", enabled);
drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, DragSpec { speed: 1.0, range: -1e7..=1e7, suffix: "" }, enabled);
ui.label(egui::RichText::new(&d.units).color(t.text_dim));
});
ui.horizontal(|ui| {
ui.label(tr("Influence:"));
let v = if out { &mut d.out_inf[dim] } else { &mut d.in_inf[dim] };
drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, 0.2, 0.1..=100.0, " %", enabled);
drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, DragSpec { speed: 0.2, range: 0.1..=100.0, suffix: " %" }, enabled);
});
ui.end_row();
}
@@ -254,14 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.end_row();
ui.label(tr("Stretch Factor:"));
let before = d.percent;
drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, 0.5, -10000.0..=10000.0, " %", true);
drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true);
if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 {
d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs();
}
ui.end_row();
ui.label(tr("New Duration:"));
let before = d.duration;
drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, 0.05, 0.01..=100000.0, " s", true);
drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true);
if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 {
d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration;
}
+142 -97
View File
@@ -87,6 +87,141 @@ fn default_view(layer: &Layer, tool: Tool) -> usize {
}
}
/// The layer's rendered view `view` at `scale`, cached in egui's temp data per revision, layer,
/// time, view and scale (the layer cache makes repeats cheap).
fn render_tex(
app: &EffectcraftApp,
ctx: &egui::Context,
cid: effectcraft_engine::project::ItemId,
comp: &effectcraft_engine::project::Comp,
layer: &Layer,
view: usize,
scale: f64,
) -> Option<Tex> {
let time = app.session.time();
let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
let cached: Option<Tex> = ctx.data(|d| d.get_temp(tex_id()));
match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
r.layer_input(&ectx, layer, view).map(|buf| {
let img = crate::frames::to_color_image(&buf.img);
let x0 = -buf.offset[0] / buf.scale;
let y0 = -buf.offset[1] / buf.scale;
let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
let tex = crate::frames::load_fitted(ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
let c = Tex { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
c
})
}
}
}
/// What [`paint_stroke`] needs about the brush and the layer <-> screen mapping.
#[derive(Clone, Copy)]
struct StrokeCtx<'a> {
layer: u64,
kind: &'a str,
zoom: f64,
did: egui::Id,
diameter: f64,
color: [f64; 4],
to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
}
/// Records a paint stroke's layer-space points while dragging, previews it, and runs
/// `paint.stroke` on release (or on a click).
fn paint_stroke(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Painter, resp: &egui::Response, sc: &StrokeCtx) {
let StrokeCtx { layer, kind, zoom, did, diameter, color, to_layer, to_screen } = *sc;
let ctx = ui.ctx().clone();
let now = ui.input(|i| i.time);
if resp.drag_started()
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
ctx.data_mut(|d| d.insert_temp(did, Drag { layer, points: vec![to_layer(pos)], started: now }));
}
if let Some(mut d) = ctx.data(|d| d.get_temp::<Drag>(did)) {
if let Some(pos) = resp.interact_pointer_pos() {
let lp = to_layer(pos);
let last = d.points.last().copied().unwrap_or(lp);
if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.0 {
d.points.push(lp);
ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
}
}
// Live preview of the stroke.
let col = if kind == "eraser" {
Color32::from_white_alpha(140)
} else {
Color32::from_rgba_unmultiplied((color[0] * 255.0) as u8, (color[1] * 255.0) as u8, (color[2] * 255.0) as u8, 200)
};
let width = ((diameter * zoom) as f32).max(1.0);
let pts: Vec<Pos2> = d.points.iter().map(|q| to_screen(*q)).collect();
if pts.len() == 1 {
painter.circle_filled(pts[0], width / 2.0, col);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
}
if resp.drag_stopped() {
ctx.data_mut(|dd| dd.remove::<Drag>(did));
let pts: Vec<[f64; 2]> = d.points.clone();
let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
if resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
let r = app.session.execute("paint.stroke", json!({"layer": layer, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
/// The bottom bar: the magnification menu and the View menu over `names` (Masks, then the
/// effects). Returns where the View dropdown ends.
fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect, names: &[String], view: usize, (zoom, fit): (f64, f32)) -> f32 {
let t = app.tokens;
p.rect_filled(bar, 0.0, t.panel_bg);
let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
if let Some((z, up)) = super::viewer_tools::magnification_menu(
app,
ui,
mr,
"layer-mag",
"layerPanel.magnification",
"layerMagnification",
zoom as f32,
app.ui.layer_zoom,
false,
) {
// The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %.
app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z };
app.ui.layer_pan = [0.0, 0.0];
}
let vx = mr.max.x + 10.0;
p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
let pop = egui::Id::unique("layer-view-pop");
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("layerPanel.view", vr, "View");
if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), names, Some(view)) {
app.ui.layer_view = Some(i);
}
vr.max.x
}
/// The Layer panel: one layer in its own space with its masks and effects up to the View menu's
/// choice, rendered synchronously at a zoom/pan the panel keeps (the last Fit magnification is
/// published for the View menu). Wheel or pinch zooms about the pointer, the Hand tool or
@@ -130,44 +265,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
let origin = area.center() + pan - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
// Bottom bar: magnification and View menus.
p.rect_filled(bar, 0.0, t.panel_bg);
let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
if let Some((z, up)) = super::viewer_tools::magnification_menu(
app,
ui,
mr,
"layer-mag",
"layerPanel.magnification",
"layerMagnification",
zoom as f32,
app.ui.layer_zoom,
false,
) {
// The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %.
app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z };
app.ui.layer_pan = [0.0, 0.0];
}
let vx = mr.max.x + 10.0;
p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
let mut names = vec!["Masks".to_string()];
if let Some(fx) = layer.effects() {
names.extend(fx.groups().map(|g| g.name.clone()));
}
let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
let pop = egui::Id::unique("layer-view-pop");
if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("layerPanel.view", vr, "View");
if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), &names, Some(view)) {
app.ui.layer_view = Some(i);
}
let vr_right = bottom_bar(app, ui, &p, bar, &names, view, (zoom, fit));
let tc = super::timecode(&app.session, &comp, time);
p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode);
// Roto Brush: view / Freeze buttons and the segmentation span bar.
if roto.is_some() {
super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer);
super::roto_tool::bar_buttons(app, ui, &p, bar, vr_right + 12.0, &layer);
let sb = Rect::from_min_max(pos2(rect.min.x, canvas_bottom), pos2(rect.max.x, bar.min.y));
super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer);
}
@@ -190,27 +298,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} else {
0.25
};
let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
let cached: Option<Tex> = ctx.data(|d| d.get_temp(tex_id()));
let tex = match cached {
Some(c) if c.key == key => Some(c),
_ => {
let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
r.expr = app.session.expr.as_deref();
r.cache = Some(&app.session.layer_cache);
r.layer_input(&ectx, &layer, view).map(|buf| {
let img = crate::frames::to_color_image(&buf.img);
let x0 = -buf.offset[0] / buf.scale;
let y0 = -buf.offset[1] / buf.scale;
let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
let tex = crate::frames::load_fitted(&ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
let c = Tex { key, tex, rect };
ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
c
})
}
};
let tex = render_tex(app, &ctx, cid, &comp, &layer, view, scale);
if let Some(tx) = &tex {
let r = Rect::from_min_max(to_screen([tx.rect[0], tx.rect[1]]), to_screen([tx.rect[2], tx.rect[3]]));
painter.image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -271,7 +359,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Interaction: Roto Brush / Refine Edge.
if app.ui.tool.roto_kind(false).is_some() {
super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer);
super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, super::roto_tool::LayerXform { to_screen: &to_screen, to_layer: &to_layer });
return;
}
// Interaction: paint tools.
@@ -314,7 +402,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.line_segment([s - vec2(0.0, 6.0), s + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::WHITE));
}
let mods = ui.input(|i| i.modifiers);
let now = ui.input(|i| i.time);
// Alt/Option-click with the Clone Stamp sets the source.
if app.ui.tool == Tool::Clone
&& mods.alt
@@ -328,49 +415,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if let Some(kind) = kind
&& !mods.alt
{
if resp.drag_started()
&& let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
{
ctx.data_mut(|d| d.insert_temp(did, Drag { layer: layer.id.0, points: vec![to_layer(pos)], started: now }));
}
if let Some(mut d) = ctx.data(|d| d.get_temp::<Drag>(did)) {
if let Some(pos) = resp.interact_pointer_pos() {
let lp = to_layer(pos);
let last = d.points.last().copied().unwrap_or(lp);
if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.0 {
d.points.push(lp);
ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
}
}
// Live preview of the stroke.
let col = if kind == "eraser" {
Color32::from_white_alpha(140)
} else {
Color32::from_rgba_unmultiplied((o.color[0] * 255.0) as u8, (o.color[1] * 255.0) as u8, (o.color[2] * 255.0) as u8, 200)
};
let width = ((o.diameter * zoom) as f32).max(1.0);
let pts: Vec<Pos2> = d.points.iter().map(|q| to_screen(*q)).collect();
if pts.len() == 1 {
painter.circle_filled(pts[0], width / 2.0, col);
} else {
painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
}
if resp.drag_stopped() {
ctx.data_mut(|dd| dd.remove::<Drag>(did));
let pts: Vec<[f64; 2]> = d.points.clone();
let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
}
if resp.clicked()
&& let Some(pos) = resp.interact_pointer_pos()
{
let r = app.session.execute("paint.stroke", json!({"layer": layer.id.0, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
}
let stroke = StrokeCtx { layer: layer.id.0, kind, zoom, did, diameter: o.diameter, color: o.color, to_layer: &to_layer, to_screen: &to_screen };
paint_stroke(app, ui, &painter, &resp, &stroke);
}
}
+22 -16
View File
@@ -63,6 +63,27 @@ fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut
thumb
}
/// The Favorites column: one clickable row per place, the current folder `dir` highlighted.
fn places_column(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, side: Rect, places: &[(String, String)], dir: &str) {
let t = app.tokens;
let mut fy = side.min.y + 22.0;
for (i, (name, path)) in places.iter().enumerate() {
let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
if path == dir {
p.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
p.rect_filled(r, 0.0, t.hover);
}
p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
if resp.clicked() {
kit::exec(app, "mediaBrowser.go", json!({"path": path}));
}
fy += 20.0;
}
}
/// The Media Browser panel: a toolbar (up, home, favourite, "Media only" filter and the browser's
/// own actions), the current path, the favourite places and a tile grid of the folder's entries
/// with thumbnails for images and videos. Double-click opens a folder or imports a file; files
@@ -136,26 +157,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 54.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y));
p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator));
p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, tr("Favorites"), Tokens::semibold(11.5), t.text_dim);
let mut fy = side.min.y + 22.0;
let mut places: Vec<(String, String)> = b.places();
places.extend(
st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())),
);
for (i, (name, path)) in places.iter().enumerate() {
let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
if *path == dir {
p.rect_filled(r, 0.0, t.row_selected);
} else if resp.hovered() {
p.rect_filled(r, 0.0, t.hover);
}
p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
if resp.clicked() {
kit::exec(app, "mediaBrowser.go", json!({"path": path}));
}
fy += 20.0;
}
places_column(app, ui, &p, side, &places, &dir);
// Files.
let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
let entries = match entries.as_ref() {
+24 -4
View File
@@ -117,7 +117,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
let lx = xv + k as f32 * 100.0;
p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::unique(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(lx + 52.0, yy - 9.0),
egui::Id::unique(("pv-roll", key)),
v,
&widgets::HotSpec { speed: 0.05, range: (0.0, 600.0), decimals: 1, suffix: "s" },
&t,
);
app.auto.add(&format!("preview.{key}"), r, text);
if let Some(nv) = nv {
changes.push(json!({"shortcut": current.id(), key: nv}));
@@ -160,7 +167,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
let sx = xv + 106.0;
p.text(pos2(sx, yy), Align2::LEFT_CENTER, tr("Skip"), Tokens::ui(12.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::unique("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(sx + 30.0, yy - 9.0),
egui::Id::unique("pv-skip"),
o.skip as f64,
&widgets::HotSpec { speed: 0.1, range: (0.0, 99.0), decimals: 0, suffix: "" },
&t,
);
app.auto.add("preview.skip", r, "Skip");
if let Some(nv) = nv {
changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
@@ -181,8 +195,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
changes.push(json!({"shortcut": current.id(), "resolution": res_ids[i]}));
}
if o.resolution == PreviewResolution::Custom {
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::unique("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(xv + 106.0, yy - 9.0),
egui::Id::unique("pv-custom-res"),
o.custom_resolution as f64,
&widgets::HotSpec { speed: 0.1, range: (1.0, 40.0), decimals: 0, suffix: "" },
&t,
);
app.auto.add("preview.customResolution", r, "Custom Resolution");
if let Some(nv) = nv {
changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32}));
+68 -44
View File
@@ -36,7 +36,14 @@ fn number(
let t = app.tokens;
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
let lw = p.layout_no_wrap(label.to_string(), Tokens::ui(11.5), t.text).size().x;
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::unique(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x + lw + 6.0, y),
egui::Id::unique(("paint-num", key)),
v,
&widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
&t,
);
app.auto.add(key, r, label);
nv.filter(|_| enabled)
}
@@ -83,6 +90,48 @@ fn paint_swap_icon(p: &egui::Painter, rect: Rect, color: Color32) {
p.arrow(pos2(right, bottom), vec2(left - right, 0.0), stroke);
}
/// The foreground-over-background swatches (each opens a colour popup) with Swap and Reset.
fn colour_swatches(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, color: [f64; 4], background: [f64; 4]) {
let t = app.tokens;
let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
widgets::swatch(ui, bg, [background[0] as f32, background[1] as f32, background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
if bresp.clicked() {
widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
}
if widgets::swatch(ui, fg, [color[0] as f32, color[1] as f32, color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
}
app.auto.add("paint.color", fg, "Foreground color");
app.auto.add("paint.background", bg, "Background color");
let mut fc = rgb(color);
if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
}
let mut bc = rgb(background);
if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
}
let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
// Two arrows (foreground over background): drawn, so the icon does not depend on a font
// carrying U+21C4 (the bundled fonts do not).
paint_swap_icon(p, swap, if sresp.hovered() { t.text } else { t.text_dim });
app.auto.add("paint.swapColors", swap, "Swap colors");
if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
set(app, json!({"color": background, "background": color}));
}
let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
app.auto.add("paint.resetColors", reset, "Default colors");
if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
}
}
/// The Paint panel: opacity and flow, foreground/background colours with swap and reset, mode,
/// channels, duration, erase mode and the Clone Stamp options. Every change is sent as a
/// `paint.options` command. Mode and Channels are disabled for the Eraser; Erase is enabled
@@ -106,43 +155,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
y += 28.0;
// Colour swatches (foreground over background) with swap / reset.
let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
if bresp.clicked() {
widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
}
if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
}
app.auto.add("paint.color", fg, "Foreground color");
app.auto.add("paint.background", bg, "Background color");
let mut fc = rgb(o.color);
if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
}
let mut bc = rgb(o.background);
if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
}
let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
// Two arrows (foreground over background): drawn, so the icon does not depend on a font
// carrying U+21C4 (the bundled fonts do not).
paint_swap_icon(&p, swap, if sresp.hovered() { t.text } else { t.text_dim });
app.auto.add("paint.swapColors", swap, "Swap colors");
if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
set(app, json!({"color": o.background, "background": o.color}));
}
let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
app.auto.add("paint.resetColors", reset, "Default colors");
if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
}
colour_swatches(app, ui, &p, x0, y, o.color, o.background);
y += 40.0;
// Mode / Channels / Duration.
@@ -167,10 +180,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
pos2(x0 + w - 52.0, y + 1.0),
egui::Id::unique("paint-custom-frames"),
o.custom_frames as f64,
0.2,
(1.0, 10000.0),
0,
" f",
&widgets::HotSpec { speed: 0.2, range: (1.0, 10000.0), decimals: 0, suffix: " f" },
&t,
);
app.auto.add("paint.customFrames", r, "Custom duration (frames)");
@@ -239,7 +249,14 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let off = o.clone_offset.unwrap_or([0.0, 0.0]);
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Offset:"), Tokens::ui(11.5), t.text_dim);
for (d, v) in off.iter().enumerate() {
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::unique(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x0 + 56.0 + d as f32 * 64.0, y),
egui::Id::unique(("clone-off", d)),
*v,
&widgets::HotSpec { speed: 1.0, range: (-1e5, 1e5), decimals: 0, suffix: "" },
&t,
);
app.auto.add(&format!("paint.cloneOffset.{d}"), r, "Clone offset");
if let Some(nv) = nv {
let mut no = off;
@@ -354,7 +371,14 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Spacing (checkbox in AE turns spacing on; we always space dabs, so it shows the value).
p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Spacing:"), Tokens::ui(11.5), t.text_dim);
let _ = r;
let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::unique("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
let (sr, nv, _) = widgets::hot_number_at(
ui,
pos2(x0 + 58.0, y),
egui::Id::unique("brushes-spacing"),
o.spacing,
&widgets::HotSpec { speed: 0.5, range: (1.0, 1000.0), decimals: 0, suffix: " %" },
&t,
);
app.auto.add("brushes.spacing", sr, "Spacing");
if let Some(nv) = nv {
set(app, json!({"spacing": nv}));
+1 -1
View File
@@ -47,7 +47,7 @@ pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) {
#[allow(clippy::too_many_arguments)]
pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
let t = app.tokens;
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, speed, range, dec, suffix, &t);
let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, &widgets::HotSpec { speed, range, decimals: dec, suffix }, &t);
app.auto.add(auto, r, &format!("{v}"));
nv.unwrap_or(v)
}
File diff suppressed because it is too large. Load diff
+29 -10
View File
@@ -91,7 +91,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---------------------------------------------------------------- Layer Transform
if let Some(tr) = layer.transform().cloned() {
section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
section_header(app, &p, x0, w, y, "Layer Transform", "properties.transform");
let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H));
let resp = ui.interact(rr, egui::Id::unique("props-reset"), Sense::click());
p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent });
@@ -124,7 +124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
divider(&p, x0, w, y - 4.0, &t);
y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
divider(&p, x0, w, y - 4.0, &t);
section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
section_header(app, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
y += ROW_H + 10.0;
// Both buttons grow with their (translated) labels; English keeps its 116 / 84 px.
let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x;
@@ -180,11 +180,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
fn section_header(app: &mut EffectcraftApp, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
let t = app.tokens;
p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H));
let _ = ui;
app.auto.add(auto, r, title);
}
@@ -286,7 +285,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
Value::Scalar(v) => {
let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
let (vr, nv, _) = widgets::hot_number_at(
ui,
pos2(vx, cy - 9.0),
egui::Id::unique(("props-v", uid)),
*v,
&widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix },
&t,
);
app.auto.add(&format!("{base}.value"), vr, &pr.name);
if let Some(nv) = nv {
set(actions, json!(nv));
@@ -311,8 +317,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
}
}
for d in 0..n.min(c.len()) {
let (vr, nv, _) =
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
let (vr, nv, _) = widgets::hot_number_at(
ui,
pos2(x, cy - 9.0),
egui::Id::unique(("props-v", uid, d)),
c[d],
&widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix },
&t,
);
app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
if let Some(nv) = nv {
let mut nc = c.clone();
@@ -358,7 +370,7 @@ fn text_section(
v["layer"] = json!(lid);
actions.push(("layer.setText".into(), v));
};
section_header(app, ui, p, x0, w, y, "Text", "properties.text");
section_header(app, p, x0, w, y, "Text", "properties.text");
y += ROW_H + 4.0;
// Font family and style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
@@ -383,7 +395,14 @@ fn text_section(
let col2 = x0 + w / 2.0;
let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::unique(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(x + 26.0, y),
egui::Id::unique(("props-text", key)),
v,
&widgets::HotSpec { speed: 0.5, range, decimals: decimals(v), suffix },
&t,
);
app.auto.add(&format!("properties.text.{key}"), r, key);
nv
};
@@ -462,7 +481,7 @@ fn paragraph_section(
actions: &mut Actions,
) -> f32 {
let t = app.tokens;
section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph");
section_header(app, p, x0, w, y, "Paragraph", "properties.paragraph");
y += ROW_H + 4.0;
for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
+214 -141
View File
@@ -415,20 +415,21 @@ fn pick_output(app: &EffectcraftApp, it: &RenderQueueItem) -> Option<Option<Stri
app.hooks.save_dialog(&app.session.resolve_output(it).unwrap_or_default())
}
/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
/// Notify, plus the progress bar), the column header, the queued items with their output template,
/// status and timings, and the footer totals. Comps dragged from the Project are queued on
/// release. The open items and the selected item are kept in egui temp data; output-template forms
/// open through a small form, and every change runs a `renderQueue.*` command.
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
/// A `renderQueue.*` command queued while drawing, run after the frame.
type Action = (&'static str, Value);
/// What the panel's sections read: the queue, the running render (if any) and the formats.
#[derive(Clone, Copy)]
struct RqView<'a> {
queue: &'a [RenderQueueItem],
progress: Option<&'a effectcraft_engine::render_queue::JobState>,
rendering: bool,
available: &'a [OutputFormat],
}
/// Comps dragged from the Project over the panel: an outline while hovering, queued on release.
fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter, rendering: bool, actions: &mut Vec<Action>) {
let t = app.tokens;
let p = ui.painter().with_clip_rect(rect);
let queue: Vec<RenderQueueItem> = app.session.project.render_queue.clone();
let progress = app.session.render_progress();
let rendering = app.session.is_rendering();
let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
let mut actions: Vec<(&'static str, Value)> = Vec::new();
app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
if !rendering
&& let Some(crate::panels::DragPayload::Item(id)) = egui::DragAndDrop::payload::<crate::panels::DragPayload>(ui.ctx()).as_deref()
&& app.session.project.comp(effectcraft_engine::project::ItemId(*id)).is_some()
@@ -448,14 +449,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
}
}
let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
let state_id = egui::Id::unique("rq-ui");
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
}
// ---------------------------------------------------------- Current Render strip
let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
p.rect_filled(top, 0.0, t.tl_ruler_bg);
let x0 = rect.min.x + 12.0;
/// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify
/// and the progress bar.
fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec<Action>) {
let t = app.tokens;
let RqView { queue, progress, rendering, available } = *rq;
let x0 = top.min.x + 12.0;
p.text(pos2(x0, top.min.y + 16.0), Align2::LEFT_CENTER, tr("Current Render"), Tokens::semibold(12.5), t.text);
let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
@@ -515,6 +516,194 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
};
p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text);
app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0));
}
/// The footer: the message line and the totals.
fn footer(p: &egui::Painter, rect: Rect, rq: &RqView, t: &Tokens) {
let RqView { queue, progress, rendering, .. } = *rq;
let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
p.rect_filled(foot, 0.0, t.tl_ruler_bg);
p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
let msg = match &progress {
Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
_ => {
let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
format!("Message: {done} of {} done", queue.len())
}
};
p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
let started = queue.iter().filter(|i| i.started.is_some()).count();
let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
p.text(
pos2(foot.max.x - 12.0, foot.center().y),
Align2::RIGHT_CENTER,
format!("Renders Started: {started} Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
Tokens::ui(11.0),
t.text_dim,
);
}
/// An item row's context menu: Duplicate, Remove and Move Up / Down (row `k` of `len`).
fn row_menu(ui: &mut egui::Ui, actions: &mut Vec<Action>, item_id: u64, k: usize, len: usize) {
if ui.button(tr("Duplicate")).clicked() {
actions.push(("renderQueue.duplicate", json!({"item": item_id})));
ui.close();
}
if ui.button(tr("Remove")).clicked() {
actions.push(("renderQueue.remove", json!({"item": item_id})));
ui.close();
}
if k > 0 && ui.button(tr("Move Up")).clicked() {
actions.push(("renderQueue.move", json!({"item": item_id, "to": k})));
ui.close();
}
if k + 1 < len && ui.button(tr("Move Down")).clicked() {
actions.push(("renderQueue.move", json!({"item": item_id, "to": k + 2})));
ui.close();
}
}
/// Log: Errors Only / Plus Settings / Plus Per Frame Info.
fn log_dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, row: &ItemRow, it: &RenderQueueItem, actions: &mut Vec<Action>) {
let ItemRow { lp, r, n, rendering, .. } = *row;
let t = app.tokens;
let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
let lid = egui::Id::unique(("rq-log", it.id));
if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
widgets::open_popup(ui, lid);
}
let llabels: Vec<String> = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
}
app.auto.add(&aid("log"), ld, it.log.label());
}
/// Where an item row's expanded line is drawn.
#[derive(Clone, Copy)]
struct ItemRow<'a> {
lp: &'a egui::Painter,
list: Rect,
r: Rect,
/// The item's 1-based position in the queue.
n: usize,
rendering: bool,
}
/// The expanded "Output To" line: the template menu, and the file name (click to choose it or
/// edit the template in place).
fn output_to(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
row: &ItemRow,
it: &RenderQueueItem,
dd_right: f32,
editing: &mut Option<(u64, String)>,
actions: &mut Vec<Action>,
) {
let ItemRow { lp, list, r, n, rendering } = *row;
let t = app.tokens;
let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
let ox = dd_right + 14.0;
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
let oid = egui::Id::unique(("rq-out", it.id));
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
widgets::open_popup(ui, oid);
}
app.auto.add(&aid("outputTo"), odd, "Output To");
let omenu = output_to_menu(it);
let olabels: Vec<String> = omenu.iter().map(|(l, _)| l.clone()).collect();
if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
if omenu[i].1.get("choose").is_some() {
match pick_output(app, it) {
Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
Some(None) => {}
None => *editing = Some((it.id, it.output.output.clone())),
}
} else {
let mut params = omenu[i].1.clone();
params["item"] = json!(it.id);
actions.push(("renderQueue.setOutput", params));
}
}
let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
if resp.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
}
*editing = None;
} else {
widgets::keep_focus(ui.ctx(), &resp, &buf);
*editing = Some((it.id, buf));
}
} else {
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
// Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
// host without one, edits the name template in place.
let dialog = app.hooks.pick_save_file.is_some();
if resp.clicked() && !rendering {
let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
match picked {
Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
Some(None) => {}
None => *editing = Some((it.id, it.output.output.clone())),
}
}
let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
app.auto.add(&aid("outputPath"), path_rect, &shown);
}
}
/// Delete removes the selected item when the panel has focus (`allowed`: not editing a path and
/// not rendering).
fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, selected: &mut Option<u64>, actions: &mut Vec<Action>) {
if app.ui.focused == crate::dock::PanelKind::RenderQueue
&& allowed
&& let Some(id) = *selected
&& app.dialog.is_none()
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
&& !ui.ctx().egui_wants_keyboard_input()
{
actions.push(("renderQueue.remove", json!({"item": id})));
*selected = None;
}
}
/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
/// Notify, plus the progress bar), the column header, the queued items with their output template,
/// status and timings, and the footer totals. Comps dragged from the Project are queued on
/// release. The open items and the selected item are kept in egui temp data; output-template forms
/// open through a small form, and every change runs a `renderQueue.*` command.
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 queue: Vec<RenderQueueItem> = app.session.project.render_queue.clone();
let progress = app.session.render_progress();
let rendering = app.session.is_rendering();
let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
let mut actions: Vec<Action> = Vec::new();
let rq = RqView { queue: &queue, progress: progress.as_ref(), rendering, available: &available };
app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
queue_drop(app, ui, rect, &p, rendering, &mut actions);
let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
let state_id = egui::Id::unique("rq-ui");
let (mut open, mut selected): (Vec<u64>, Option<u64>) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
// ---------------------------------------------------------- Current Render strip
let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
p.rect_filled(top, 0.0, t.tl_ruler_bg);
current_render_strip(app, ui, &p, top, &rq, &mut actions);
// ---------------------------------------------------------- column header
let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H));
@@ -575,25 +764,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
selected = Some(it.id);
app.ui.focused = crate::dock::PanelKind::RenderQueue;
}
let item_id = it.id;
crate::widgets::context_menu(&row_resp, |ui| {
if ui.button(tr("Duplicate")).clicked() {
actions.push(("renderQueue.duplicate", json!({"item": item_id})));
ui.close();
}
if ui.button(tr("Remove")).clicked() {
actions.push(("renderQueue.remove", json!({"item": item_id})));
ui.close();
}
if k > 0 && ui.button(tr("Move Up")).clicked() {
actions.push(("renderQueue.move", json!({"item": item_id, "to": n - 1})));
ui.close();
}
if n < queue.len() && ui.button(tr("Move Down")).clicked() {
actions.push(("renderQueue.move", json!({"item": item_id, "to": n + 1})));
ui.close();
}
});
crate::widgets::context_menu(&row_resp, |ui| row_menu(ui, &mut actions, it.id, k, queue.len()));
app.auto.add(&aid("row"), row, &format!("#{n}"));
let is_open = open.contains(&it.id);
let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
@@ -686,78 +857,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
);
lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
}
// Log: Errors Only / Plus Settings / Plus Per Frame Info.
let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
let lid = egui::Id::unique(("rq-log", it.id));
if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
widgets::open_popup(ui, lid);
}
let llabels: Vec<String> = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
}
app.auto.add(&aid("log"), ld, it.log.label());
log_dropdown(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, &mut actions);
continue;
}
// Output To.
let ox = dd.max.x + 14.0;
lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
let oid = egui::Id::unique(("rq-out", it.id));
if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
widgets::open_popup(ui, oid);
}
app.auto.add(&aid("outputTo"), odd, "Output To");
let omenu = output_to_menu(it);
let olabels: Vec<String> = omenu.iter().map(|(l, _)| l.clone()).collect();
if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
if omenu[i].1.get("choose").is_some() {
match pick_output(app, it) {
Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
Some(None) => {}
None => editing = Some((it.id, it.output.output.clone())),
}
} else {
let mut params = omenu[i].1.clone();
params["item"] = json!(it.id);
actions.push(("renderQueue.setOutput", params));
}
}
let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
if resp.lost_focus() {
if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
}
editing = None;
} else {
widgets::keep_focus(ui.ctx(), &resp, &buf);
editing = Some((it.id, buf));
}
} else {
let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
// Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
// host without one, edits the name template in place.
let dialog = app.hooks.pick_save_file.is_some();
if resp.clicked() && !rendering {
let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
match picked {
Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
Some(None) => {}
None => editing = Some((it.id, it.output.output.clone())),
}
}
let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
app.auto.add(&aid("outputPath"), path_rect, &shown);
}
output_to(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, dd.max.x, &mut editing, &mut actions);
}
}
ui.set_clip_rect(panel_clip);
@@ -770,40 +873,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
None => d.remove::<(u64, String)>(editing_id),
});
// Delete removes the selected item when the panel has focus.
if app.ui.focused == crate::dock::PanelKind::RenderQueue
&& editing.is_none()
&& !rendering
&& let Some(id) = selected
&& app.dialog.is_none()
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
&& !ui.ctx().egui_wants_keyboard_input()
{
actions.push(("renderQueue.remove", json!({"item": id})));
selected = None;
}
delete_selected(app, ui, editing.is_none() && !rendering, &mut selected, &mut actions);
// ---------------------------------------------------------- footer
let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
p.rect_filled(foot, 0.0, t.tl_ruler_bg);
p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
let msg = match &progress {
Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
_ => {
let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
format!("Message: {done} of {} done", queue.len())
}
};
p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
let started = queue.iter().filter(|i| i.started.is_some()).count();
let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
p.text(
pos2(foot.max.x - 12.0, foot.center().y),
Align2::RIGHT_CENTER,
format!("Renders Started: {started} Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
Tokens::ui(11.0),
t.text_dim,
);
footer(&p, rect, &rq, &t);
open.retain(|id| queue.iter().any(|i| i.id == *id));
ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
+8 -10
View File
@@ -135,17 +135,15 @@ pub fn draw_overlay(
}
}
/// The layer <-> screen mappings [`interact`] works with.
pub struct LayerXform<'a> {
pub to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
pub to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
}
/// Pointer handling for the Roto Brush / Refine Edge tools. Returns true when it handled input.
pub fn interact(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
area: Rect,
layer: &Layer,
zoom: f64,
to_screen: &dyn Fn([f64; 2]) -> Pos2,
to_layer: &dyn Fn(Pos2) -> [f64; 2],
) -> bool {
pub fn interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, area: Rect, layer: &Layer, zoom: f64, xf: LayerXform) -> bool {
let LayerXform { to_screen, to_layer } = xf;
let ctx = ui.ctx().clone();
let mods = ui.input(|i| i.modifiers);
let Some(kind) = app.ui.tool.roto_kind(mods.alt) else { return false };
+26 -13
View File
@@ -302,19 +302,20 @@ fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
painter.line_segment([pos2(cti, graph.top()), pos2(cti, graph.bottom())], egui::Stroke::new(1.0, p.cti));
}
/// What [`theme_card`] shows: the setting `key`, the translated `title`, whether the family is
/// `active`, the `cur`rent theme and the `options` (value, label) to pick from.
struct ThemeFamily<'a> {
key: &'a str,
title: &'a str,
active: bool,
cur: &'a str,
options: &'a [(&'a str, &'a str)],
}
/// One theme family's card: its (translated) name, Active when the appearance shows it now, a preview of the
/// chosen theme and a radio button per theme.
fn theme_card(
app: &mut EffectcraftApp,
ui: &mut egui::Ui,
t: &Tokens,
key: &str,
title: &str,
active: bool,
cur: &str,
options: &[(&str, &str)],
acts: &mut Vec<Act>,
) {
fn theme_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, fam: ThemeFamily, acts: &mut Vec<Act>) {
let ThemeFamily { key, title, active, cur, options } = fam;
egui::Frame::new()
.fill(t.field_bg)
.stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border }))
@@ -396,8 +397,20 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T
let light_active = mode == "light" || (mode == "auto" && system_light == Some(true));
ui.columns(2, |cols| {
let [l, d] = cols else { return };
theme_card(app, l, t, "appearance.lightTheme", tr("Light Theme"), light_active, &light, LIGHT_THEMES, acts);
theme_card(app, d, t, "appearance.darkTheme", tr("Dark Theme"), !light_active, &dark, DARK_THEMES, acts);
theme_card(
app,
l,
t,
ThemeFamily { key: "appearance.lightTheme", title: tr("Light Theme"), active: light_active, cur: &light, options: LIGHT_THEMES },
acts,
);
theme_card(
app,
d,
t,
ThemeFamily { key: "appearance.darkTheme", title: tr("Dark Theme"), active: !light_active, cur: &dark, options: DARK_THEMES },
acts,
);
});
ui.add_space(6.0);
}
+120 -75
View File
@@ -64,7 +64,8 @@ pub fn text_target(app: &EffectcraftApp) -> Option<TextTarget> {
/// A small toggle button at `r` showing `label` (semibold 13 when `bold`, else 12), filled with
/// the accent while `on`; returns whether it was clicked. It only reports the click: the caller
/// decides the new state.
fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, look: (&str, bool, bool)) -> bool {
let (label, on, bold) = look;
let t = app.tokens;
let resp = ui.interact(r, egui::Id::unique(("text-toggle", id)), Sense::click());
p.rect_filled(
@@ -84,6 +85,103 @@ fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rec
resp.clicked()
}
/// Position of the numeric grid (`x0`, top `y`, width `w`) and whether edits are collected.
#[derive(Clone, Copy)]
struct Grid {
x0: f32,
y: f32,
w: f32,
enabled: bool,
}
/// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 left).
fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
let t = app.tokens;
let Grid { x0, y, w, enabled } = g;
let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
("size", "T", doc.size, (1.0, 2000.0), " px"),
("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
("", "", 0.0, (0.0, 0.0), ""),
("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
("", "", 0.0, (0.0, 0.0), ""),
("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
];
for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
if key.is_empty() {
continue;
}
let col = i % 2;
let row = i / 2;
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + row as f32 * 28.0;
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(fx + 26.0, fy),
egui::Id::unique(("char", key)),
v,
&widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
&t,
);
app.auto.add(&format!("character.{key}"), r, key);
if let Some(nv) = nv
&& enabled
{
actions.push(json!({key: nv, "merge": format!("char-{key}")}));
}
}
}
/// Kerning: Metrics / Optical / a manual value (grid row 1, left).
fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
let t = app.tokens;
let Grid { x0, y, w, enabled } = g;
let fy = y + 28.0;
p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
let label = match doc.kerning {
Kerning::Metrics => "Metrics".to_string(),
Kerning::Optical => "Optical".to_string(),
Kerning::Manual(v) => format!("{v:.0}"),
};
let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
let kpop = egui::Id::unique("char-kerning-pop");
if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
widgets::open_popup(ui, kpop);
}
app.auto.add("character.kerning", kr, "Kerning");
let opts: Vec<String> = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
let v = match i {
0 => json!("metrics"),
1 => json!("optical"),
_ => json!(opts[i].parse::<f64>().unwrap_or(0.0)),
};
actions.push(json!({"kerning": v}));
}
}
/// Stroke Over Fill / Fill Over Stroke (grid row 2, right).
fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: &TextDoc, actions: &mut Vec<serde_json::Value>) {
let t = app.tokens;
let Grid { x0, y, w, enabled } = g;
let fx = x0 + w / 2.0;
let fy = y + 2.0 * 28.0;
let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
let pop = egui::Id::unique("char-stroke-order-pop");
if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add("character.strokeOverFill", dr, "Stroke order");
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
actions.push(json!({"strokeOverFill": i == 1}));
}
}
/// The Character panel for the text being edited (or the selected text layer): font family and
/// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
/// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
@@ -122,76 +220,10 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
actions.push(json!({"style": st}));
}
y += 34.0;
// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 right).
let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
("size", "T", doc.size, (1.0, 2000.0), " px"),
("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
("", "", 0.0, (0.0, 0.0), ""),
("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
("", "", 0.0, (0.0, 0.0), ""),
("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
];
for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
if key.is_empty() {
continue;
}
let col = i % 2;
let row = i / 2;
let fx = x0 + col as f32 * (w / 2.0);
let fy = y + row as f32 * 28.0;
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, 0.5, range, 0, suffix, &t);
app.auto.add(&format!("character.{key}"), r, key);
if let Some(nv) = nv
&& enabled
{
actions.push(json!({key: nv, "merge": format!("char-{key}")}));
}
}
// Kerning: Metrics / Optical / a manual value (row 1, left).
{
let fy = y + 28.0;
p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
let label = match doc.kerning {
Kerning::Metrics => "Metrics".to_string(),
Kerning::Optical => "Optical".to_string(),
Kerning::Manual(v) => format!("{v:.0}"),
};
let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
let kpop = egui::Id::unique("char-kerning-pop");
if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
widgets::open_popup(ui, kpop);
}
app.auto.add("character.kerning", kr, "Kerning");
let opts: Vec<String> = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
let v = match i {
0 => json!("metrics"),
1 => json!("optical"),
_ => json!(opts[i].parse::<f64>().unwrap_or(0.0)),
};
actions.push(json!({"kerning": v}));
}
}
// Stroke Over Fill / Fill Over Stroke (row 2, right).
{
let fx = x0 + w / 2.0;
let fy = y + 2.0 * 28.0;
let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
let pop = egui::Id::unique("char-stroke-order-pop");
if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add("character.strokeOverFill", dr, "Stroke order");
if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
actions.push(json!({"strokeOverFill": i == 1}));
}
}
let grid = Grid { x0, y, w, enabled };
numeric_fields(app, ui, &p, grid, &doc, &mut actions);
kerning_field(app, ui, &p, grid, &doc, &mut actions);
stroke_order_field(app, ui, grid, &doc, &mut actions);
y += 5.0 * 28.0 + 8.0;
// Fill / stroke swatches with their enable boxes.
for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
@@ -242,7 +274,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let bw = ((w - 5.0 * 4.0) / 6.0).clamp(22.0, 30.0);
for (i, (key, label, on, bold)) in toggles.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y), vec2(bw, 24.0));
if toggle(app, ui, &p, r, &format!("character.{key}"), label, on, bold) && enabled {
if toggle(app, ui, &p, r, &format!("character.{key}"), (label, on, bold)) && enabled {
actions.push(json!({key: !on}));
}
}
@@ -296,8 +328,14 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tag = a.tag.trim_end().to_string();
let v = doc.variations.iter().chain(&instance).find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
let (r, nv, _) =
widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::unique(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(fx + 34.0, fy),
egui::Id::unique(("char-axis", &tag)),
v as f64,
&widgets::HotSpec { speed: 1.0, range: (a.min as f64, a.max as f64), decimals: 0, suffix: "" },
&t,
);
app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
if let Some(nv) = nv
&& enabled
@@ -517,7 +555,14 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let fx = x0 + (i % 2) as f32 * (w / 2.0);
let fy = y + (i / 2) as f32 * 28.0;
p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
let (r, nv, _) = widgets::hot_number_at(
ui,
pos2(fx + 26.0, fy),
egui::Id::unique(("para-f", key)),
v,
&widgets::HotSpec { speed: 0.5, range: (-5000.0, 5000.0), decimals: 0, suffix: " px" },
&t,
);
app.auto.add(&format!("paragraph.{key}"), r, key);
if let Some(nv) = nv
&& enabled
File diff suppressed because it is too large. Load diff
+28 -24
View File
@@ -115,6 +115,32 @@ fn analyze_glyph(p: &egui::Painter, r: Rect, forward: bool, frame: bool, stop: b
}
}
/// The two button rows at `y`: Track Camera / Warp Stabilizer, then Track Motion / Stabilize Motion.
fn top_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, x0: f32, y: f32, half: f32, source: Option<LayerId>) {
let can_track = app.session.active_comp().is_some() && source.is_some();
// Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
run(app, ctx, "track.camera", json!({}));
}
let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
run(app, ctx, "track.warpStabilizer", json!({}));
}
let y = y + 28.0;
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
run(app, ctx, "track.motion", src_p.clone());
}
if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
run(app, ctx, "track.stabilize", src_p.clone());
}
}
/// The Tracker panel: the camera/warp and motion/stabilize buttons, Motion Source and Current
/// Track pickers, the track type and its channels, Motion Target with Edit Target and Options, the
/// four Analyze buttons (the one whose direction is running offers Stop), Reset and Apply, and the
@@ -132,33 +158,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let w = (rect.width() - 20.0).max(120.0);
let half = (w - 6.0) / 2.0;
let mut y = rect.min.y + 10.0;
let has_comp = app.session.active_comp().is_some();
let source = motion_source(app);
let cur = current(app);
let can_track = has_comp && source.is_some();
// Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
run(app, &ctx, "track.camera", json!({}));
}
let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
run(app, &ctx, "track.warpStabilizer", json!({}));
}
y += 28.0;
let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
run(app, &ctx, "track.motion", src_p.clone());
}
if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
run(app, &ctx, "track.stabilize", src_p.clone());
}
y += 34.0;
top_buttons(app, ui, &ctx, x0, y, half, source);
y += 62.0;
let Some(comp) = app.session.active_comp_arc() else {
p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("Open a composition to track motion."), Tokens::ui(12.0), t.text_faint);
return;
+474 -82
View File
@@ -72,6 +72,52 @@ thread_local! {
/// (move/scale/rotate/anchor/camera/shape/mask/guide edits, picks, marquees); failures worth showing go to
/// `app.ui.status`, while per-frame drag updates ignore errors so a gesture is not aborted mid-drag. With no active
/// composition it draws `empty_state` and returns.
/// What the passes of one viewer frame share (see [`show`]).
struct Vw<'a> {
t: Tokens,
cid: effectcraft_engine::project::ItemId,
nav: Rect,
bar: Rect,
err_bar: Rect,
err_h: f32,
pasteboard: Color32,
area: Rect,
outer: Rect,
comp_rect: Rect,
zoom: f32,
ppp: f32,
cw: f32,
ch: f32,
map: ViewerMap,
time: Tick,
overlays: bool,
mods: egui::Modifiers,
space_pan: bool,
text_owns: bool,
gid: egui::Id,
track_cur: Option<(LayerId, effectcraft_engine::project::Uid)>,
has_3d: bool,
ctx: egui::Context,
comp_name: String,
p: egui::Painter,
painter: egui::Painter,
selected: Vec<LayerId>,
sel_vertices: Vec<effectcraft_engine::VertexRef>,
ft: Option<ov::FtBox>,
comp: &'a Comp,
gesture: Option<Gesture>,
handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)>,
axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])>,
vertex_hits: Vec<VertexHit>,
vertex_marks: Vec<ov::VertexMark>,
key_hits: Vec<ov::KeyHit>,
motion_frames: Vec<ov::PathFrame>,
paths: Vec<ov::PathInfo>,
track_hits: Vec<super::tracker::Hit>,
pin_hits: Vec<super::puppet_tool::PinHit>,
puppet_layers: Vec<(&'a Layer, super::puppet_tool::Overlay)>,
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let t = app.tokens;
let ctx = ui.ctx().clone();
@@ -83,7 +129,75 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let p = ui.painter().clone();
VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
let mut v = Vw {
t,
cid,
nav: Rect::NOTHING,
bar: Rect::NOTHING,
err_bar: Rect::NOTHING,
err_h: 0.0,
pasteboard: Color32::BLACK,
area: Rect::NOTHING,
outer: Rect::NOTHING,
comp_rect: Rect::NOTHING,
zoom: 1.0,
ppp: 1.0,
cw: 0.0,
ch: 0.0,
map: ViewerMap { origin: Pos2::ZERO, zoom: 1.0, comp: [0.0; 2], area: Rect::NOTHING },
time: Tick::ZERO,
overlays: false,
mods: egui::Modifiers::NONE,
space_pan: false,
text_owns: false,
gid: egui::Id::unique("viewer-gesture"),
track_cur: None,
has_3d: false,
ctx: ctx.clone(),
comp_name,
painter: p.clone(),
p,
selected: vec![],
sel_vertices: vec![],
ft: None,
comp: &comp,
gesture: None,
handle_hits: vec![],
axis_hits: vec![],
vertex_hits: vec![],
vertex_marks: vec![],
key_hits: vec![],
motion_frames: vec![],
paths: vec![],
track_hits: vec![],
pin_hits: vec![],
puppet_layers: vec![],
};
phase_nav(app, ui, rect, &mut v);
phase_layout(app, ui, rect, &mut v);
phase_frame(app, &mut v);
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: v.time, expr: snap_expr.as_deref(), footage: None };
phase_guides(app, &ectx, &mut v);
phase_layers(app, ui, &ectx, &mut v);
phase_scene(app, &ectx, &mut v);
phase_rigs(app, ui, &ectx, &mut v);
let resp = phase_menu(app, ui, &ectx, &mut v);
phase_hover(app, ui, &ectx, &resp, &mut v);
phase_press(app, ui, &ectx, &resp, &mut v);
phase_dragstart(app, ui, &ectx, &resp, &mut v);
phase_drag(app, ui, &ectx, &resp, &mut v);
phase_stopped(app, ui, &ectx, &resp, &mut v);
phase_clicked(app, &ectx, &resp, &mut v);
phase_dblclick(app, &ectx, &resp, &mut v);
phase_tail(app, ui, &ectx, &mut v);
}
fn phase_nav(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
let t = v.t;
let cid = v.cid;
let p = v.p.clone();
// Navigator bar (open comps as breadcrumbs). After Effects shows a single breadcrumb row:
// when the Composition Navigator (nested comp flow) is above the viewer, this row is left
// out.
@@ -116,7 +230,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
x = r.max.x + 4.0;
}
v.nav = nav;
}
fn phase_layout(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
let t = v.t;
let cid = v.cid;
let nav = v.nav;
let ctx = v.ctx.clone();
let p = v.p.clone();
let comp = v.comp;
// Bottom control bar.
let bar_h = 36.0;
let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
@@ -128,7 +251,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
// keeps the overlays and the interaction.
let full = locked_pane(app, ui, full, pasteboard);
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
let area = aux_views(app, ui, comp, cid, full, pasteboard);
p.rect_filled(area, 0.0, pasteboard);
// View ▸ Show Rulers takes a strip on the top and left.
let outer = area;
@@ -151,8 +274,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let painter = p.with_clip_rect(area);
// Settings ▸ 3D ▸ Extended Viewer: custom 3D views (and Draft 3D) render the visible
// pasteboard too, so 3D layers reaching past the comp frame stay visible there.
app.ui.viewer.extended = extended_region(app, &comp, cid, map);
app.ui.viewer.extended = extended_region(app, comp, cid, map);
v.bar = bar;
v.err_bar = err_bar;
v.err_h = err_h;
v.pasteboard = pasteboard;
v.area = area;
v.outer = outer;
v.comp_rect = comp_rect;
v.zoom = zoom;
v.ppp = ppp;
v.cw = cw;
v.ch = ch;
v.map = map;
v.painter = painter;
}
fn phase_frame(app: &mut EffectcraftApp, v: &mut Vw<'_>) {
let cid = v.cid;
let comp_rect = v.comp_rect;
let zoom = v.zoom;
let ppp = v.ppp;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let comp = v.comp;
// Frame.
let scale = app.viewer_scale(zoom, ppp);
if !app.playback.playing {
@@ -185,11 +330,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let bg = comp.background;
painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
}
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, expr: snap_expr.as_deref(), footage: None };
v.time = time;
}
fn phase_guides(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
let cid = v.cid;
let area = v.area;
let comp_rect = v.comp_rect;
let zoom = v.zoom;
let map = v.map;
let ctx = v.ctx.clone();
let comp_name = v.comp_name.clone();
let painter = v.painter.clone();
let comp = v.comp;
// The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
vt::draw_frame(app, &ctx, &painter, comp_rect, cid, ectx);
if app.ui.viewer.extended.is_some() {
// The comp frame outlined over the extended render.
painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
@@ -275,17 +430,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
app.auto.add("viewer.regionOfInterest", r, "Region of interest");
}
v.overlays = overlays;
}
fn phase_layers(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
let t = v.t;
let zoom = v.zoom;
let cw = v.cw;
let ch = v.ch;
let map = v.map;
let time = v.time;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let mut handle_hits = std::mem::take(&mut v.handle_hits);
let mut axis_hits = std::mem::take(&mut v.axis_hits);
let mut vertex_hits = std::mem::take(&mut v.vertex_hits);
let mut vertex_marks = std::mem::take(&mut v.vertex_marks);
let mut key_hits = std::mem::take(&mut v.key_hits);
let mut motion_frames = std::mem::take(&mut v.motion_frames);
let mut paths = std::mem::take(&mut v.paths);
let comp = v.comp;
// Overlays + interaction.
let selected = app.session.state.selected_layers.clone();
// Bounding-box handles: (layer, handle, screen point, its resize cursor).
let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new();
// 3D gizmo arrows: (layer, grabbable screen segment, position change per comp px in x and y).
let mut axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> = Vec::new();
let mut vertex_hits: Vec<VertexHit> = Vec::new();
let mut vertex_marks: Vec<ov::VertexMark> = Vec::new();
let mut key_hits: Vec<ov::KeyHit> = Vec::new();
let mut motion_frames: Vec<ov::PathFrame> = Vec::new();
let mut paths: Vec<ov::PathInfo> = Vec::new();
let sel_vertices = app.session.state.selected_vertices.clone();
// Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
// free transform.
@@ -308,7 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for l in comp.layers.iter().filter(|l| {
matches!(l.source, LayerSource::Null) && !selected.contains(&l.id) && l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo)
}) {
let outline = layer_outline(&ectx, l);
let outline = layer_outline(ectx, l);
if outline.len() > 2 {
let col = if label_handles { t.label(l.label) } else { t.accent };
painter.add(egui::Shape::closed_line(outline.into_iter().map(|p| map.to_screen(p)).collect(), Stroke::new(1.0, col)));
@@ -322,12 +490,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && (l.switches.video || l.source.is_av())) {
let col = if label_handles { t.label(l.label) } else { t.accent };
// Motion path of animated position (keyframes and spatial tangents are draggable).
ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames);
ov::motion_path(app, &painter, &map, ectx, l, col, &mut key_hits, &mut motion_frames);
// Masks and shape paths.
if app.ui.viewer.show_masks {
let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
let hover = ui.input(|i| i.pointer.hover_pos());
ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
ov::draw_paths(app, &painter, &map, ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
}
// Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a
// selected collapsed precomp.
@@ -336,7 +504,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
&& let Some(nc) = app.session.project.comp(*item)
{
let (outer, _) = l2c(&ectx, l);
let (outer, _) = l2c(ectx, l);
let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.nested_time(l));
for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) {
if let Some((_, q, _)) = layer_quad(&nctx, nl) {
@@ -359,9 +527,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if editing_path {
continue;
}
let Some((m, q, _)) = layer_quad(&ectx, l) else {
let Some((m, q, _)) = layer_quad(ectx, l) else {
// A 3D layer reaching past the camera: the part of its box in front of it.
let outline = layer_outline(&ectx, l);
let outline = layer_outline(ectx, l);
if outline.len() > 2 {
painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col)));
continue;
@@ -396,10 +564,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// 3D axis gizmo.
if l.is_3d() {
let w = ectx.world_matrix(l);
let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w;
let pc = cam_state(ectx).projection(cw as f64, ch as f64) * w;
let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]);
let len = 60.0 / zoom as f64;
let pinv = parent_world_inverse(&ectx, l);
let pinv = parent_world_inverse(ectx, l);
for (axis, col, name) in [
(effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "x"),
(effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "y"),
@@ -428,7 +596,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
v.handle_hits = handle_hits;
v.axis_hits = axis_hits;
v.vertex_hits = vertex_hits;
v.vertex_marks = vertex_marks;
v.key_hits = key_hits;
v.motion_frames = motion_frames;
v.paths = paths;
v.selected = selected;
v.sel_vertices = sel_vertices;
}
fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
let t = v.t;
let cid = v.cid;
let area = v.area;
let comp_rect = v.comp_rect;
let cw = v.cw;
let ch = v.ch;
let map = v.map;
let time = v.time;
let painter = v.painter.clone();
let selected = v.selected.clone();
let vertex_marks = std::mem::take(&mut v.vertex_marks);
let mut track_hits = std::mem::take(&mut v.track_hits);
let comp = v.comp;
// Path points last, over the boxes' handles (#1042).
ov::draw_vertex_marks(&painter, &vertex_marks, app.session.prefs.general.path_point_size as f32 + 1.0);
@@ -437,7 +629,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time));
if has_3d && app.session.prefs.three_d.extended_viewer {
for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) {
let q = layer_outline(&ectx, l);
let q = layer_outline(ectx, l);
if q.len() > 2 {
let sq: Vec<Pos2> = q.iter().map(|p| map.to_screen(*p)).collect();
let outside = sq.iter().any(|p| !comp_rect.contains(*p));
@@ -448,7 +640,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
if has_3d && app.session.prefs.three_d.show_reference_axes {
let pc = cam_state(&ectx).projection(cw as f64, ch as f64);
let pc = cam_state(ectx).projection(cw as f64, ch as f64);
let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0);
let o = pc.apply(o3);
let corner = pos2(area.min.x + 34.0, area.max.y - 34.0);
@@ -467,7 +659,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Track points of the current track (Tracker panel) on its layer, while that layer is selected.
let mut track_hits = vec![];
let track_cur = super::tracker::viewer_track(app);
if app.layer_controls_visible()
&& let Some((tl, tu)) = track_cur
@@ -475,7 +666,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(layer) = comp.layer(tl)
&& layer.is_active_at(time)
{
let (m, _) = l2c(&ectx, layer);
let (m, _) = l2c(ectx, layer);
track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
}
@@ -487,7 +678,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(layer) = comp.layer(wl)
&& layer.is_active_at(time)
{
let (m, _) = l2c(&ectx, layer);
let (m, _) = l2c(ectx, layer);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
let at = map.to_screen([c.x, c.y]);
@@ -509,7 +700,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if notices >= 3 {
break;
}
let (m, _) = l2c(&ectx, layer);
let (m, _) = l2c(ectx, layer);
let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
let at = map.to_screen([c.x, c.y]);
@@ -522,14 +713,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text);
notices += 1;
}
v.vertex_marks = vertex_marks;
v.track_hits = track_hits;
v.track_cur = track_cur;
v.has_3d = has_3d;
}
fn phase_rigs(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
let t = v.t;
let cid = v.cid;
let area = v.area;
let map = v.map;
let time = v.time;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let selected = v.selected.clone();
let paths = std::mem::take(&mut v.paths);
let mut pin_hits = std::mem::take(&mut v.pin_hits);
let mut puppet_layers = std::mem::take(&mut v.puppet_layers);
let comp = v.comp;
// Free transform box of a path (double-click a path with the Selection tool).
let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);
// Cameras and lights (wireframes) and the 3D view name.
if comp.has_3d() {
let through_active = VIEW_CAM.with(|c| c.get()).is_none();
draw_rigs(app, &painter, &map, &ectx, &selected, cam_state(&ectx), through_active, None);
draw_rigs(app, &painter, &map, ectx, &selected, cam_state(ectx), through_active, None);
let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
let label = match (view, comp.active_camera(time)) {
(View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name),
@@ -539,10 +748,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// Puppet pins and meshes of the selected layers (Puppet tools).
let mut pin_hits: Vec<super::puppet_tool::PinHit> = vec![];
// The selected layers with a Puppet effect, top of the stack first (the layers the Puppet
// tools work on).
let mut puppet_layers: Vec<(&Layer, super::puppet_tool::Overlay)> = vec![];
if app.ui.tool.puppet_kind().is_some() {
let sel_props: Vec<u64> = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
for lid in &selected {
@@ -550,7 +757,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& let Some(ov) = super::puppet_tool::overlay(app, &ctx, cid, l, time)
{
let hover = ui.input(|i| i.pointer.hover_pos());
pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
pin_hits.extend(super::puppet_tool::draw(&painter, &map, ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
puppet_layers.push((l, ov));
}
}
@@ -564,17 +771,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
// The layer a Puppet tool press works on: while selected layers are rigged, only those
// ([`super::puppet_tool::target`]); otherwise the layer under the pointer, selected.
let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option<LayerId> {
if puppet_layers.is_empty() {
return pick(app, &ectx, cpt, false);
}
super::puppet_tool::target(&ectx, &puppet_layers, cpt, |l| layers_at(&ectx, cpt).any(|h| h.id == l.id))
};
v.paths = paths;
v.pin_hits = pin_hits;
v.puppet_layers = puppet_layers;
v.ft = ft;
}
fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) -> egui::Response {
let cid = v.cid;
let area = v.area;
let map = v.map;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let comp = v.comp;
// Project items, files and effects dropped on the viewer.
super::viewer_drop::show(app, ui, &painter, &map, &ectx);
super::viewer_drop::show(app, ui, &painter, &map, ectx);
// Interaction.
let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag());
@@ -584,9 +795,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
{
// Picking an already selected layer preserves the group; an unselected hit
// becomes the target, using the same visible/unlocked hit test as left-click.
pick(app, &ectx, map.to_comp(pos), false);
pick(app, ectx, map.to_comp(pos), false);
let point = map.to_comp(pos);
let hits: Vec<(u64, String)> = layers_at(&ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
let hits: Vec<(u64, String)> = layers_at(ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
ui.data_mut(|d| d.insert_temp(menu_hits_id, hits));
}
let context_layers: Vec<u64> =
@@ -641,6 +852,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.status = e;
}
}
resp
}
fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
let area = v.area;
let zoom = v.zoom;
let cw = v.cw;
let ch = v.ch;
let map = v.map;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let handle_hits = std::mem::take(&mut v.handle_hits);
let axis_hits = std::mem::take(&mut v.axis_hits);
let vertex_hits = std::mem::take(&mut v.vertex_hits);
let key_hits = std::mem::take(&mut v.key_hits);
let motion_frames = std::mem::take(&mut v.motion_frames);
let paths = std::mem::take(&mut v.paths);
let track_hits = std::mem::take(&mut v.track_hits);
let pin_hits = std::mem::take(&mut v.pin_hits);
let comp = v.comp;
// Path vertices and tangent handles take the press before the bounding-box handles (#1042).
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
@@ -675,7 +906,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// The tool draws its own crosshair (and the layer it would matte) while no box
// is being dragged.
if ui.data(|d| d.get_temp::<Gesture>(egui::Id::unique("viewer-gesture"))).is_none() {
super::object_matte_tool::hover(app, &painter, &map, &ectx, hp);
super::object_matte_tool::hover(app, &painter, &map, ectx, hp);
}
egui::CursorIcon::None
}
@@ -689,7 +920,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
_ => {
if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
egui::CursorIcon::Move
} else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
} else if let Some((_, vertical)) = vt::guide_at(app, comp, &map, hp) {
if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
} else if vertex_at(hp, true).or_else(|| vertex_at(hp, false)).is_some() {
// A path point under a box handle is grabbed first: no resize cursor.
@@ -719,6 +950,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
} else {
app.pointer_comp = None;
}
v.handle_hits = handle_hits;
v.axis_hits = axis_hits;
v.vertex_hits = vertex_hits;
v.key_hits = key_hits;
v.motion_frames = motion_frames;
v.paths = paths;
v.track_hits = track_hits;
v.pin_hits = pin_hits;
v.gid = gid;
}
fn phase_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
let map = v.map;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let key_hits = std::mem::take(&mut v.key_hits);
let motion_frames = std::mem::take(&mut v.motion_frames);
let paths = std::mem::take(&mut v.paths);
app.ui.viewer.interacting = resp.dragged();
let mods = ui.input(|i| i.modifiers);
let space_pan = super::space_hand(&ctx);
@@ -730,7 +979,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
&& ui.input(|i| i.pointer.primary_pressed())
&& let Some(pos) = ui.input(|i| i.pointer.press_origin())
{
pen_press(app, ui, &ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
pen_press(app, ui, ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
}
// A pen click (press + release without a drag) ends its tangent gesture on release.
if ui.input(|i| i.pointer.primary_released())
@@ -740,7 +989,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.data_mut(|d| d.remove::<Gesture>(egui::Id::unique("viewer-gesture")));
}
// On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
let text_owns = super::viewer_text::hook(app, ui, &painter, &map, ectx, resp, space_pan);
v.key_hits = key_hits;
v.motion_frames = motion_frames;
v.paths = paths;
v.mods = mods;
v.space_pan = space_pan;
v.text_owns = text_owns;
}
fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
let comp_rect = v.comp_rect;
let zoom = v.zoom;
let map = v.map;
let time = v.time;
let mods = v.mods;
let space_pan = v.space_pan;
let text_owns = v.text_owns;
let gid = v.gid;
let track_cur = v.track_cur;
let ft = std::mem::take(&mut v.ft);
let handle_hits = std::mem::take(&mut v.handle_hits);
let axis_hits = std::mem::take(&mut v.axis_hits);
let vertex_hits = std::mem::take(&mut v.vertex_hits);
let key_hits = std::mem::take(&mut v.key_hits);
let paths = std::mem::take(&mut v.paths);
let track_hits = std::mem::take(&mut v.track_hits);
let pin_hits = std::mem::take(&mut v.pin_hits);
let puppet_layers = std::mem::take(&mut v.puppet_layers);
let comp = v.comp;
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
};
let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option<LayerId> {
if puppet_layers.is_empty() {
return pick(app, ectx, cpt, false);
}
super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
};
if resp.drag_started()
&& !text_owns
&& let Some(pos) = resp.interact_pointer_pos()
@@ -760,8 +1047,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => {
ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay)
}
Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => {
vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
Tool::Selection if vt::guide_at(app, comp, &map, press).is_some() => {
vt::guide_at(app, comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
}
// Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull
// new tangents out of it.
@@ -771,9 +1058,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press }
})
}
Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
Tool::MaskFeather => ov::begin_feather(app, ectx, &paths, &map, press).map(Gesture::Overlay),
Tool::Rotate => pick(app, ectx, cpt, mods.shift).map(|l| {
let others = selected_others(app, comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
let layer = comp.layer(l).cloned();
let center = layer
.as_ref()
@@ -790,10 +1077,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
others,
}
}),
Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| {
let layer = comp.layer(l)?;
let tr = layer.transform()?;
let (l2c, _) = l2c(&ectx, layer);
let (l2c, _) = l2c(ectx, layer);
let l2p = ectx.local_matrix(layer);
let l2p = Mat3([[l2p.0[0][0], l2p.0[0][1], 0.0], [l2p.0[1][0], l2p.0[1][1], 0.0], [0.0, 0.0, 1.0]]);
Some(Gesture::Anchor {
@@ -804,7 +1091,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
start: map.to_comp(press),
inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
l2p,
own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false, app.session.state.snap_features),
own: effectcraft_engine::viewer::layer_targets(ectx, layer, false, app.session.state.snap_features),
anchor_only: mods.alt,
})
}),
@@ -830,7 +1117,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
PinPart::Center => {}
}
let l = comp.layer(h.layer)?;
let inv = fx2c(&ectx, l).inverse()?;
let inv = fx2c(ectx, l).inverse()?;
if mods.command {
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let began = ui.input(|i| i.time);
@@ -850,7 +1137,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let hit = super::tracker::hit_at(&track_hits, press);
track_cur.zip(hit).and_then(|((tl, tu), hit)| {
let layer = comp.layer(tl)?;
let (m, _) = l2c(&ectx, layer);
let (m, _) = l2c(ectx, layer);
super::tracker::begin_drag(layer, tu, time, &m, hit, map.to_comp(press)).map(|drag| Gesture::Track { layer: tl, tracker: tu, drag })
})
}
@@ -890,11 +1177,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let layer = comp.layer(lid).cloned();
layer.and_then(|layer| {
let tr = layer.transform()?;
let (l2c, _) = l2c(&ectx, &layer);
let (l2c, _) = l2c(ectx, &layer);
let inv = l2c.inverse()?;
let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
let lp = inv.apply(gv2(cpt[0], cpt[1]));
let hp = handle_point(effectcraft_engine::render::content_bounds(&ectx, &layer)?, hi);
let hp = handle_point(effectcraft_engine::render::content_bounds(ectx, &layer)?, hi);
Some(Gesture::Scale {
layer: lid,
start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
@@ -904,13 +1191,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
axes: handle_axes(hi),
l2c,
inv,
others: selected_others(app, &comp, lid)
others: selected_others(app, comp, lid)
.into_iter()
.filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3]))))
.collect(),
})
})
} else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
} else if let Some(l) = pick(app, ectx, cpt, mods.shift) {
let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
.session
.state
@@ -920,12 +1207,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
.filter(|l| !l.switches.locked)
.filter_map(|l| {
let p0 = ectx.v3(l, l.transform()?, "position", [0.0; 3]);
let (ax, ay) = drag_axes(&ectx, l);
let (ax, ay) = drag_axes(ectx, l);
Some((l.id, p0, ax, ay))
})
.collect();
// Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(ectx, layer)).and_then(|f| {
let c = map.to_comp(press);
f.into_iter().min_by(|a, b| {
let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
@@ -954,6 +1241,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.data_mut(|d| d.insert_temp(gid, g));
}
}
v.ft = ft;
v.handle_hits = handle_hits;
v.axis_hits = axis_hits;
v.vertex_hits = vertex_hits;
v.key_hits = key_hits;
v.paths = paths;
v.track_hits = track_hits;
v.pin_hits = pin_hits;
v.puppet_layers = puppet_layers;
}
fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
let t = v.t;
let zoom = v.zoom;
let map = v.map;
let mods = v.mods;
let gid = v.gid;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let gesture: Option<Gesture> = ui.data(|d| d.get_temp(gid));
if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
let cpt = map.to_comp(pos);
@@ -970,7 +1276,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
if let Some(src) = snap_src {
let ids: Vec<LayerId> = layers.iter().map(|x| x.0).collect();
let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
let c = vt::snap(app, &ctx, ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
d = [d[0] + c[0], d[1] + c[1]];
if vt::snapping_on(app, mods) {
vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]]));
@@ -987,7 +1293,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
// The handle snaps (to the comp's and other layers' edges, corners and centres),
// so a layer scales exactly to them, e.g. to the comp's size.
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, ectx, &map, &[layer], &[p], mods));
for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
let v = [s0[0] * f[0], s0[1] * f[1], s0[2]];
let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
@@ -1013,7 +1319,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
let want = [anchor_c.x + d[0], anchor_c.y + d[1]];
let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own);
let sc = vt::snap_with(app, &ctx, ectx, &map, &vt::SnapQuery { exclude: &[layer], sources: &[want], mods, extra: &own });
let a = inv.apply(gv2(start[0], start[1]));
let b = inv.apply(gv2(start[0] + d[0] + sc[0], start[1] + d[1] + sc[1]));
let dl = gv2(b.x - a.x, b.y - a.y);
@@ -1048,7 +1354,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Gesture::Vertices { start, src, applied, inv, layer } => {
// The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
let want = [cpt[0] - start[0], cpt[1] - start[1]];
let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
let total = [want[0] + c[0], want[1] + c[1]];
let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
if d.x != 0.0 || d.y != 0.0 {
@@ -1067,7 +1373,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
}
d => {
let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods);
let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, ectx, &map, &[l], &[p], mods);
if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) {
ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd)));
}
@@ -1148,6 +1454,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
v.gesture = gesture;
}
fn phase_stopped(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
let area = v.area;
let comp_rect = v.comp_rect;
let map = v.map;
let time = v.time;
let mods = v.mods;
let gid = v.gid;
let vertex_hits = std::mem::take(&mut v.vertex_hits);
let pin_hits = std::mem::take(&mut v.pin_hits);
let gesture = v.gesture.clone();
let comp = v.comp;
if resp.drag_stopped() {
if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) {
match g {
@@ -1181,7 +1501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, &ectx, &map, start, Some(pos), mods.shift),
Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, ectx, &map, start, Some(pos), mods.shift),
Gesture::Create { tool, start } => {
let end = map.to_comp(pos);
create_shape(app, tool, start, end, mods);
@@ -1214,8 +1534,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
Gesture::Marquee { start } => {
let a = map.to_comp(start);
let b = map.to_comp(pos);
let hits: Vec<u64> = selectable_layers(&comp, time)
.filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
let hits: Vec<u64> = selectable_layers(comp, time)
.filter(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
.map(|l| l.id.0)
.collect();
let _ = app.session.execute("layer.select", json!({"layers": hits, "toggle": mods.shift}));
@@ -1229,6 +1549,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
d.insert_temp(egui::Id::unique("viewer-drag-n"), n + 1);
});
}
v.vertex_hits = vertex_hits;
v.pin_hits = pin_hits;
}
fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
let area = v.area;
let zoom = v.zoom;
let cw = v.cw;
let ch = v.ch;
let map = v.map;
let mods = v.mods;
let text_owns = v.text_owns;
let ctx = v.ctx.clone();
let ft = std::mem::take(&mut v.ft);
let handle_hits = std::mem::take(&mut v.handle_hits);
let vertex_hits = std::mem::take(&mut v.vertex_hits);
let key_hits = std::mem::take(&mut v.key_hits);
let motion_frames = std::mem::take(&mut v.motion_frames);
let paths = std::mem::take(&mut v.paths);
let pin_hits = std::mem::take(&mut v.pin_hits);
let puppet_layers = std::mem::take(&mut v.puppet_layers);
let comp = v.comp;
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
};
let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option<LayerId> {
if puppet_layers.is_empty() {
return pick(app, ectx, cpt, false);
}
super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
};
if resp.clicked()
&& !text_owns
&& let Some(pos) = resp.interact_pointer_pos()
@@ -1285,13 +1637,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Clicking away from the free-transform box ends it.
ov::end_free_transform(&ctx);
}
Tool::ObjectMatte => super::object_matte_tool::apply(app, &ectx, &map, pos, None, mods.shift),
Tool::ObjectMatte => super::object_matte_tool::apply(app, ectx, &map, pos, None, mods.shift),
t if t.puppet_kind().is_some() => {
if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
// Click selects the pin; Shift-click adds or removes it.
let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
} else if let Some(l) = puppet_layer(app, cpt).and_then(|l| comp.layer(l))
&& let Some(inv) = fx2c(&ectx, l).inverse()
&& let Some(inv) = fx2c(ectx, l).inverse()
{
let lp = inv.apply(gv2(cpt[0], cpt[1]));
match app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})) {
@@ -1312,13 +1664,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
// The Roto Brush paints in the Layer panel only: select as usual and say so.
rt if rt.roto_kind(false).is_some() => {
if pick(app, &ectx, cpt, mods.shift).is_none() {
if pick(app, ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
}
app.ui.status = crate::i18n::tr(ROTO_HINT).to_string();
}
_ => {
if pick(app, &ectx, cpt, mods.shift).is_none() {
if pick(app, ectx, cpt, mods.shift).is_none() {
let _ = app.session.execute("edit.deselectAll", json!({}));
} else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() {
// A click away from the paths, even on their layer, deselects the mask
@@ -1328,6 +1680,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
v.ft = ft;
v.handle_hits = handle_hits;
v.vertex_hits = vertex_hits;
v.key_hits = key_hits;
v.motion_frames = motion_frames;
v.paths = paths;
v.pin_hits = pin_hits;
v.puppet_layers = puppet_layers;
}
fn phase_dblclick(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
let map = v.map;
let text_owns = v.text_owns;
let ctx = v.ctx.clone();
let handle_hits = std::mem::take(&mut v.handle_hits);
let vertex_hits = std::mem::take(&mut v.vertex_hits);
let paths = std::mem::take(&mut v.paths);
let comp = v.comp;
let vertex_at = |pos: Pos2, tangents: bool| -> Option<VertexHit> {
let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
};
if resp.double_clicked()
&& !text_owns
&& app.ui.tool == Tool::Selection
@@ -1349,7 +1723,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
{
let cpt = map.to_comp(pos);
let roto = app.ui.tool.roto_kind(false).is_some();
if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
if let Some(l) = pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) {
match &l.source {
effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
// With a Roto Brush tool, double-clicking a layer opens it in the Layer panel,
@@ -1365,15 +1739,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
}
v.handle_hits = handle_hits;
v.vertex_hits = vertex_hits;
v.paths = paths;
}
fn phase_tail(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
let t = v.t;
let cid = v.cid;
let bar = v.bar;
let err_bar = v.err_bar;
let err_h = v.err_h;
let area = v.area;
let outer = v.outer;
let zoom = v.zoom;
let map = v.map;
let time = v.time;
let ctx = v.ctx.clone();
let painter = v.painter.clone();
let comp = v.comp;
// 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
// the effect is selected), and its analysis banner.
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
// Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
// Selected shape gradients' start and end points (#612).
crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
// Selected text Range Selectors' Start and End bars (#1011).
crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
// Status chips (render time / caching).
if app.playback.playing && app.playback.waiting {
@@ -1388,14 +1781,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("viewer-video-off-chip"), r));
}
vt::draw_snap(&ctx, &painter, &map, &ectx);
vt::draw_snap(&ctx, &painter, &map, ectx);
vt::rulers(app, ui, &map, outer, area);
vt::bottom_bar(app, ui, bar, zoom, time, &comp);
vt::bottom_bar(app, ui, bar, zoom, time, comp);
if err_h > 0.0 {
super::expr_bar::draw(app, ui, err_bar, cid);
}
}
pub(crate) use crate::widgets::hex_rgb;
mod frame;
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{Align2, EffectcraftApp, Pos2, Rect, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, vec2, vt, widgets};
/// Draw the no-composition viewer with New Composition and New Composition From Footage buttons, accepting Project
/// items dropped on it to create the first composition.
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{CameraState, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, VIEW_CAM, gv2, three_d, vec2};
/// The camera the viewer shows (view camera or the comp's active camera).
pub(super) fn cam_state(ctx: &EvalCtx) -> CameraState {
+1 -1
View File
@@ -1,4 +1,4 @@
use super::*;
use super::{EffectcraftApp, LayerId, Mat3, Pos2, Rect, Tick, Tool, ViewerMap, ov, vec2};
/// What a drag in the viewer is doing.
#[derive(Clone, Debug)]
+4 -1
View File
@@ -1,5 +1,8 @@
use super::geometry::{l2c, layer_quad, point_in_quad};
use super::*;
use super::{
Color32, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, LayerSource, Mat3, PaintKind, Pos2, Rect, Stroke, Tick, Tokens, Tool, ViewerMap, gv2, json, ov,
pos2,
};
/// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order.
/// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After
+1 -1
View File
@@ -1,6 +1,6 @@
use super::geometry::{cam_state, l2c, parent_inverse};
use super::gesture::{Gesture, pen_id};
use super::*;
use super::{CameraState, Color32, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, Rect, Stroke, Tool, ViewerMap, gv2, json, ov, three_d, vec2, vt};
/// The position change per comp pixel of drag (x, y) that moves a layer by `dir` when the
/// pointer covers the axis' on-screen vector `s` (comp px); drag across the axis is ignored.
+1 -1
View File
@@ -1,5 +1,5 @@
use super::tools::draw_rigs;
use super::*;
use super::{Align2, Color32, EffectcraftApp, EvalCtx, Rect, Sense, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, three_d, vec2, widgets};
/// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle.
pub(super) fn aux_views(
+207 -173
View File
@@ -810,6 +810,91 @@ fn typing_key(ui: &egui::Ui, bump: bool) -> String {
format!("text-typing-{n}")
}
/// A press inside the edited text layer: grab a box handle, place the caret (shift extends from
/// `shift_anchor`), or, outside the text, commit the edit. Returns the drag it starts, if any.
fn press_on_edited(app: &mut EffectcraftApp, ui: &egui::Ui, map: &ViewerMap, e: &Edited, pos: Pos2, shift_anchor: Option<usize>) -> Option<Drag> {
if let Some(h) = e.handles().iter().position(|h| e.to_screen(map, *h).distance(pos) < 7.0) {
let b = e.doc.box_size.unwrap_or([0.0; 2]);
return Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
}
if e.contains(map, pos) {
let ci = e.hit(map, pos);
exec(app, "text.setSelection", json!({"anchor": shift_anchor.unwrap_or(ci), "caret": ci}));
typing_key(ui, true);
return Some(Drag::Select { layer: e.layer.id });
}
// Clicking elsewhere commits the edit.
exec(app, "text.endEdit", json!({}));
None
}
/// A press with the Type tool that missed the edited text: edit the text layer under it, or
/// start creating a new one.
fn press_type_tool(app: &mut EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, pos: Pos2, tool: Tool) -> Drag {
if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
let ci = e.hit(map, pos);
exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
Drag::Select { layer: e.layer.id }
} else {
Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical }
}
}
/// The paragraph box after dragging `handle` by (`dx`, `dy`) in layer space.
fn resized_box(start_box: [f64; 4], handle: usize, dx: f64, dy: f64) -> [f64; 4] {
let [mut x0, mut y0, w, h] = start_box;
let (mut x1, mut y1) = (x0 + w, y0 + h);
if matches!(handle, 0 | 6 | 7) {
x0 += dx;
}
if matches!(handle, 2..=4) {
x1 += dx;
}
if matches!(handle, 0..=2) {
y0 += dy;
}
if matches!(handle, 4..=6) {
y1 += dy;
}
[x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)]
}
/// Keyboard routing for the text editor: while a session is live, keep the focus on it and feed
/// it this frame's keys; otherwise release the IME and the focus.
fn keyboard_focus(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
// Register after pointer handling so new edits take focus in this frame. An inactive
// editor must not take Tab focus and immediately surrender it before viewer controls.
ui.interact(Rect::from_min_size(rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| {
m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
});
// A session started elsewhere (menu, agent, Properties) takes the keyboard once…
let seen = ime_seen_id(ui.ctx().viewport_id());
let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
// …and gets it back when a click in the viewer or a formatting panel left nothing holding
// it; not merely because nothing has the focus (#831).
let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
if ui.data(|d| d.get_temp::<Option<(u64, u64)>>(seen)).flatten() != key || regain {
ui.data_mut(|d| d.insert_temp(seen, key));
ui.memory_mut(|m| m.request_focus(focus_id()));
}
if ui.memory(|m| m.has_focus(focus_id())) {
keyboard(app, ui, rect);
}
} else {
// Resolve the viewport before taking the data lock: `viewport_id()`
// acquires the same context lock that `data_mut` holds.
let viewport = ui.ctx().viewport_id();
ui.data_mut(|d| d.insert_temp::<Option<(u64, u64)>>(ime_seen_id(viewport), None));
invalidate_ime_composition(ui, app);
clear_preedit(ui);
if ui.memory(|m| m.has_focus(focus_id())) {
ui.memory_mut(|m| m.surrender_focus(focus_id()));
}
}
}
/// Viewer hook: draws the edit overlay and handles text pointer and keyboard input. Returns
/// true when it owns this frame's pointer interaction (the viewer then skips its own gestures).
pub fn hook(
@@ -858,7 +943,6 @@ pub fn hook(
}
});
let pointer = ui.input(|i| i.pointer.interact_pos());
let handle_hit = |e: &Edited, s: Pos2| e.handles().iter().position(|h| e.to_screen(map, *h).distance(s) < 7.0);
// ---- press
if pressed
@@ -870,30 +954,14 @@ pub fn hook(
{
let mut handled = false;
if let Some(e) = &cur {
if let Some(h) = handle_hit(e, pos) {
let b = e.doc.box_size.unwrap_or([0.0; 2]);
drag = Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
let shift_anchor = if mods.shift { Some(editing.as_ref().map_or_else(|| e.hit(map, pos), |sel| sel.anchor)) } else { None };
if let Some(d) = press_on_edited(app, ui, map, e, pos, shift_anchor) {
drag = Some(d);
handled = true;
} else if e.contains(map, pos) {
let ci = e.hit(map, pos);
let anchor = if mods.shift { editing.as_ref().map_or(ci, |sel| sel.anchor) } else { ci };
exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
typing_key(ui, true);
drag = Some(Drag::Select { layer: e.layer.id });
handled = true;
} else {
// Clicking elsewhere commits the edit.
exec(app, "text.endEdit", json!({}));
}
}
if !handled && type_tool {
if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
let ci = e.hit(map, pos);
exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
drag = Some(Drag::Select { layer: e.layer.id });
} else {
drag = Some(Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical });
}
drag = Some(press_type_tool(app, ectx, map, pos, tool));
handled = true;
}
if handled {
@@ -948,22 +1016,7 @@ pub fn hook(
{
let (px, py) = e.to_layer(map, p);
let (qx, qy) = e.to_layer(map, q);
let (dx, dy) = (px - qx, py - qy);
let [mut x0, mut y0, w, h] = start_box;
let (mut x1, mut y1) = (x0 + w, y0 + h);
if matches!(handle, 0 | 6 | 7) {
x0 += dx;
}
if matches!(handle, 2..=4) {
x1 += dx;
}
if matches!(handle, 0..=2) {
y0 += dy;
}
if matches!(handle, 4..=6) {
y1 += dy;
}
let bx = [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)];
let bx = resized_box(start_box, handle, px - qx, py - qy);
exec(app, "layer.setText", json!({"layer": layer.0, "box": bx, "merge": format!("text-box-{}", layer.0)}));
}
}
@@ -1009,37 +1062,7 @@ pub fn hook(
// composition's identity as a stale guard before this pass's IME events are read.
sync_ime_target(ui, app);
sync_ime_selection(ui, app);
if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
// Register after pointer handling so new edits take focus in this frame. An inactive
// editor must not take Tab focus and immediately surrender it before viewer controls.
ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
ui.memory_mut(|m| {
m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
});
// A session started elsewhere (menu, agent, Properties) takes the keyboard once…
let seen = ime_seen_id(ui.ctx().viewport_id());
let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
// …and gets it back when a click in the viewer or a formatting panel left nothing holding
// it; not merely because nothing has the focus (#831).
let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
if ui.data(|d| d.get_temp::<Option<(u64, u64)>>(seen)).flatten() != key || regain {
ui.data_mut(|d| d.insert_temp(seen, key));
ui.memory_mut(|m| m.request_focus(focus_id()));
}
if ui.memory(|m| m.has_focus(focus_id())) {
keyboard(app, ui, resp.rect);
}
} else {
// Resolve the viewport before taking the data lock: `viewport_id()`
// acquires the same context lock that `data_mut` holds.
let viewport = ui.ctx().viewport_id();
ui.data_mut(|d| d.insert_temp::<Option<(u64, u64)>>(ime_seen_id(viewport), None));
invalidate_ime_composition(ui, app);
clear_preedit(ui);
if ui.memory(|m| m.has_focus(focus_id())) {
ui.memory_mut(|m| m.surrender_focus(focus_id()));
}
}
keyboard_focus(app, ui, resp.rect);
// ---- overlay
if let Some(s) = app.session.state.text_edit.clone()
@@ -1219,111 +1242,7 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
}
typing_key(ui, true);
}
egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
use egui::Key;
let cmd = m.command;
let mv = |app: &mut EffectcraftApp, to: &str| {
exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
};
match key {
Key::ArrowLeft | Key::ArrowRight if m.alt => {
// Kerning at the caret, tracking over a selection.
let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
if sel.is_empty() {
if sel.caret < doc.char_len() {
let cur = match doc.style_at(sel.caret).kerning {
Kerning::Manual(v) => v,
_ => 0.0,
};
exec(
app,
"layer.setText",
json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
);
}
} else {
let r = sel.range();
let cur = doc.style_at(r.start).tracking;
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
}
}
Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
Key::Backspace => {
let k = typing_key(ui, false);
let before = ime_stamp(app, &ctx);
if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
note_ime_application_edit(app, &ctx, before);
}
}
Key::Delete => {
let k = typing_key(ui, false);
let before = ime_stamp(app, &ctx);
if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
note_ime_application_edit(app, &ctx, before);
}
}
Key::Enter if cmd => {
exec(app, "text.endEdit", json!({}));
}
Key::Enter => {
let k = typing_key(ui, false);
let before = ime_stamp(app, &ctx);
if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
note_ime_application_edit(app, &ctx, before);
}
}
Key::Tab => {
let k = typing_key(ui, false);
let before = ime_stamp(app, &ctx);
if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
note_ime_application_edit(app, &ctx, before);
}
}
Key::Escape => {
exec(app, "text.endEdit", json!({}));
}
// The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
// without Shift (Undo, Redo). Other modifiers on those keys aren't editing
// chords, so they fall through to the binding match below: Shift+A is
// Deselect All's default, and Alt variants are free for a rebound one.
Key::A if cmd && !m.shift && !m.alt => {
exec(app, "text.setSelection", json!({"select": "all"}));
}
Key::Z if cmd && !m.alt => {
let before = ime_stamp(app, &ctx);
if crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
note_ime_application_edit(app, &ctx, before);
}
}
Key::Comma | Key::Period if cmd && m.shift => {
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
let r = sel.range();
let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
}
// The app-wide shortcut dispatcher is skipped while this editor holds the
// keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
// must be matched here and run through the command dispatcher like undo
// above. Matching the command's current binding, not a fixed key, keeps
// rebound shortcuts working and gives every route one final state; the
// forwarding test also refuses chords a focused editor must never surrender
// (see `forwardable_during_text_edit`).
_ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
let _ = crate::menus::invoke(app, &ctx, "edit.deselectAll", json!({}));
}
_ => {}
}
if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
typing_key(ui, true);
}
}
egui::Event::Key { key, pressed: true, modifiers: m, .. } => key_event(app, ui, &ctx, &sel, key, m),
_ => {}
}
let route_ended = app.session.state.text_edit.is_none() || app.dialog.is_some();
@@ -1344,6 +1263,121 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
}
}
/// An application key press in the edited text: caret moves, deletes, insertions, formatting and
/// the editor's own chords.
fn key_event(
app: &mut EffectcraftApp,
ui: &egui::Ui,
ctx: &egui::Context,
sel: &effectcraft_engine::commands::text_edit::TextEdit,
key: egui::Key,
m: egui::Modifiers,
) {
use egui::Key;
let cmd = m.command;
let mv = |app: &mut EffectcraftApp, to: &str| {
exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
};
match key {
Key::ArrowLeft | Key::ArrowRight if m.alt => {
// Kerning at the caret, tracking over a selection.
let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
if sel.is_empty() {
if sel.caret < doc.char_len() {
let cur = match doc.style_at(sel.caret).kerning {
Kerning::Manual(v) => v,
_ => 0.0,
};
exec(
app,
"layer.setText",
json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
);
}
} else {
let r = sel.range();
let cur = doc.style_at(r.start).tracking;
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
}
}
Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
Key::Backspace => {
let k = typing_key(ui, false);
let before = ime_stamp(app, ctx);
if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
note_ime_application_edit(app, ctx, before);
}
}
Key::Delete => {
let k = typing_key(ui, false);
let before = ime_stamp(app, ctx);
if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
note_ime_application_edit(app, ctx, before);
}
}
Key::Enter if cmd => {
exec(app, "text.endEdit", json!({}));
}
Key::Enter => {
let k = typing_key(ui, false);
let before = ime_stamp(app, ctx);
if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
note_ime_application_edit(app, ctx, before);
}
}
Key::Tab => {
let k = typing_key(ui, false);
let before = ime_stamp(app, ctx);
if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
note_ime_application_edit(app, ctx, before);
}
}
Key::Escape => {
exec(app, "text.endEdit", json!({}));
}
// The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
// without Shift (Undo, Redo). Other modifiers on those keys aren't editing
// chords, so they fall through to the binding match below: Shift+A is
// Deselect All's default, and Alt variants are free for a rebound one.
Key::A if cmd && !m.shift && !m.alt => {
exec(app, "text.setSelection", json!({"select": "all"}));
}
Key::Z if cmd && !m.alt => {
let before = ime_stamp(app, ctx);
if crate::menus::invoke(app, ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
note_ime_application_edit(app, ctx, before);
}
}
Key::Comma | Key::Period if cmd && m.shift => {
let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
let r = sel.range();
let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
}
// The app-wide shortcut dispatcher is skipped while this editor holds the
// keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
// must be matched here and run through the command dispatcher like undo
// above. Matching the command's current binding, not a fixed key, keeps
// rebound shortcuts working and gives every route one final state; the
// forwarding test also refuses chords a focused editor must never surrender
// (see `forwardable_during_text_edit`).
_ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
let _ = crate::menus::invoke(app, ctx, "edit.deselectAll", json!({}));
}
_ => {}
}
if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
typing_key(ui, true);
}
}
/// Delete the text an IME marks for replacement around the editor's selection.
///
/// `before`/`after` count UTF-8 bytes (`DeleteSurroundingBytes`, what winit reports) or
+34 -28
View File
@@ -48,20 +48,21 @@ pub(crate) fn snap(
sources: &[[f64; 2]],
mods: egui::Modifiers,
) -> [f64; 2] {
snap_with(app, ctx, ectx, map, exclude, sources, mods, &[])
snap_with(app, ctx, ectx, map, &SnapQuery { exclude, sources, mods, extra: &[] })
}
/// [`snap`] with `extra` layer targets (the Pan Behind layer's own box).
pub(crate) fn snap_with(
app: &EffectcraftApp,
ctx: &egui::Context,
ectx: &EvalCtx,
map: &ViewerMap,
exclude: &[LayerId],
sources: &[[f64; 2]],
mods: egui::Modifiers,
extra: &[vw::SnapTarget],
) -> [f64; 2] {
/// What [`snap_with`] snaps: the dragged features and the layers to ignore, plus `extra` layer
/// targets (the Pan Behind layer's own box).
pub(crate) struct SnapQuery<'a> {
pub exclude: &'a [LayerId],
pub sources: &'a [[f64; 2]],
pub mods: egui::Modifiers,
pub extra: &'a [vw::SnapTarget],
}
/// [`snap`] with extra layer targets.
pub(crate) fn snap_with(app: &EffectcraftApp, ctx: &egui::Context, ectx: &EvalCtx, map: &ViewerMap, q: &SnapQuery) -> [f64; 2] {
let SnapQuery { exclude, sources, mods, extra } = *q;
let layers = snapping_on(app, mods);
let v = &app.ui.viewer;
let guides = v.snap_guides && v.guides;
@@ -620,6 +621,26 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
});
}
/// The exposure drag field (stops) at `er`; a change runs `view.exposure`.
fn exposure_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, er: Rect, stops: f64) {
let mut ev = stops;
let resp = ui.put(
er,
crate::widgets::drag_value(
egui::DragValue::new(&mut ev)
.speed(0.05)
.range(-40.0..=40.0)
.fixed_decimals(1)
.custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
),
);
let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
if resp.changed() {
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
}
}
/// Lay out the viewer's control row from `bar.min.x`: magnification, resolution, transparency, mask, ROI, grid/guide,
/// channel/colour management, exposure, snapshot, fast previews, 3D renderer/view and Extended Viewer, timecode and render-time
/// indicators, running commands or toggling viewer UI state. Returns the width the row
@@ -743,22 +764,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
let _ = app.session.execute("view.resetExposure", json!({}));
}
let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
let mut ev = vo.exposure as f64;
let resp = ui.put(
er,
crate::widgets::drag_value(
egui::DragValue::new(&mut ev)
.speed(0.05)
.range(-40.0..=40.0)
.fixed_decimals(1)
.custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
),
);
let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
if resp.changed() {
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
}
exposure_field(app, ui, er, vo.exposure as f64);
x = er.max.x + 4.0;
// Snapshots.
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
+1 -4
View File
@@ -67,10 +67,7 @@ impl Scrub {
pos2(100.0, 100.0),
Id::unique("scrub"),
self.value,
1.0,
(-1e9, 1e9),
1,
"",
&crate::widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" },
&crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
);
if let Some(v) = value {
+13 -12
View File
@@ -51,20 +51,21 @@ fn tab_stop(ui: &Ui, id: egui::Id) -> bool {
})
}
/// How a hot-text field scrubs and prints: `speed` value units per pointer pixel, clamped to
/// `range`, shown with `decimals` places followed by `suffix`.
#[derive(Clone, Copy)]
pub struct HotSpec<'a> {
pub speed: f64,
pub range: (f64, f64),
pub decimals: usize,
pub suffix: &'a str,
}
/// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab
/// while typing commits and types in the panel's next / previous one, as in After Effects.
/// Returns (response, Some(new value) when changed, drag finished).
pub fn hot_number_at(
ui: &mut Ui,
rect_min: egui::Pos2,
id: egui::Id,
value: f64,
speed: f64,
range: (f64, f64),
decimals: usize,
suffix: &str,
t: &Tokens,
) -> (Rect, Option<f64>, bool) {
pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64, spec: &HotSpec, t: &Tokens) -> (Rect, Option<f64>, bool) {
let HotSpec { speed, range, decimals, suffix } = *spec;
let editing_id = id.with("editing");
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
if tab_stop(ui, id) && editing.is_none() {
@@ -163,7 +164,7 @@ pub fn slider_scrub_speed(ui: &ParamUi) -> f64 {
/// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
/// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, speed: f64, suffix: &str, t: &Tokens) -> (Rect, Option<i64>, bool) {
let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, speed, (-1e6, 1e6), 0, suffix, t);
let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, &HotSpec { speed, range: (-1e6, 1e6), decimals: 0, suffix }, t);
let acc_id = id.with("acc");
let acc = ui.data(|d| d.get_temp::<f64>(acc_id)).unwrap_or(0.0) + nv.map_or(0.0, |n| n - value as f64);
// (Ten 0.1 steps add up to just under 1.)