mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 00:59:00 +00:00
Refactor ui-egui for strict Clippy checks
This commit is contained in:
1 parent
f20d22bf36
commit
6e72ab3b57
47 files changed
+6246
-3812
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,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`]).
|
||||
|
||||
@@ -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,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
@@ -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,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,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::UiCommand;
|
||||
|
||||
macro_rules! uic {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(©_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);
|
||||
|
||||
@@ -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
@@ -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![
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+2634
-1514
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||
|
||||
@@ -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,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,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,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)]
|
||||
|
||||
@@ -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,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,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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.)
|
||||
|
||||
Reference in new issue
Block a user