mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #238 from storytold/fix/227-interface
Spacebar hand tool, Effect Controls menu, New Comp drop, switch drags (#227)
This commit is contained in:
14 files changed
+457
-27
No files matched your search
@@ -404,7 +404,8 @@ fn set_switch(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_p(p, "switch").ok_or_else(|| bad("layer.setSwitch", "missing `switch`"))?.to_string();
|
||||
let v = b_p(p, "value");
|
||||
let label = format!("Layer Switch ({name})");
|
||||
let r = s.edit(&label, None, |proj, _| {
|
||||
// `merge`: a switch dragged over several layers in the Timeline is one undo step.
|
||||
let r = s.edit(&label, merge_p(p), |proj, _| {
|
||||
let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let mut last = false;
|
||||
// Toggle relative to the first layer so a multi-selection ends up consistent.
|
||||
@@ -1344,7 +1345,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Layer Switch",
|
||||
[],
|
||||
None,
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?}",
|
||||
"{layers?, switch: video|audio|solo|lock|shy|collapse|quality|fx|frameBlend|motionBlur|adjustment|threeD|guide|preserveTransparency, value?, merge?}",
|
||||
has_layers,
|
||||
set_switch
|
||||
),
|
||||
|
||||
@@ -1396,6 +1396,12 @@ fn mods_match(want: egui::Modifiers, got: egui::Modifiers) -> bool {
|
||||
want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.command && cfg!(not(target_os = "macos")))
|
||||
}
|
||||
|
||||
/// Set (egui temp data) from a plain Spacebar press until its release while a tap would still
|
||||
/// run the Spacebar shortcut.
|
||||
fn space_tap_id() -> egui::Id {
|
||||
egui::Id::new("spacebar-tap")
|
||||
}
|
||||
|
||||
/// Dispatch keyboard shortcuts (skipped while typing in a text field).
|
||||
pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
// Dialog cancellation owns Escape even when a text field has keyboard focus.
|
||||
@@ -1420,12 +1426,17 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
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()));
|
||||
}
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
// Text editing in the viewer takes the clipboard events itself.
|
||||
let clipboard = app.session.state.text_edit.is_none();
|
||||
let events: Vec<(egui::Key, egui::Modifiers)> = ctx.input(|i| {
|
||||
let events: Vec<(egui::Key, egui::Modifiers, bool)> = 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.
|
||||
@@ -1436,11 +1447,12 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
egui::Event::Key { key, pressed: true, modifiers, repeat, .. }
|
||||
if !*repeat || matches!(key, egui::Key::PageUp | egui::Key::PageDown | egui::Key::ArrowLeft | egui::Key::ArrowRight) =>
|
||||
{
|
||||
Some((*key, *modifiers))
|
||||
Some((*key, *modifiers, true))
|
||||
}
|
||||
egui::Event::Copy if clipboard => Some((egui::Key::C, held)),
|
||||
egui::Event::Cut if clipboard => Some((egui::Key::X, held)),
|
||||
egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held)),
|
||||
egui::Event::Key { key: egui::Key::Space, pressed: false, .. } => Some((egui::Key::Space, egui::Modifiers::NONE, false)),
|
||||
egui::Event::Copy if clipboard => Some((egui::Key::C, held, true)),
|
||||
egui::Event::Cut if clipboard => Some((egui::Key::X, held, true)),
|
||||
egui::Event::Paste(_) if clipboard => Some((egui::Key::V, held, true)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
@@ -1449,7 +1461,20 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
||||
return;
|
||||
}
|
||||
let binds = bindings(&app.session);
|
||||
for (key, mods) in events {
|
||||
for (key, mods, pressed) in events {
|
||||
// Plain Spacebar runs its shortcut (Play Current Preview) on the release: held, it is
|
||||
// the Hand tool (After Effects), and a drag with it must not start or stop playback.
|
||||
if key == egui::Key::Space && (!pressed || !mods.any()) {
|
||||
if pressed {
|
||||
if app.dialog.is_none() {
|
||||
ctx.data_mut(|d| d.insert_temp(space_tap_id(), true));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !ctx.data_mut(|d| d.remove_temp::<bool>(space_tap_id())).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Escape closes dialogs.
|
||||
if key == egui::Key::Escape && app.dialog.is_some() {
|
||||
// Escape while recording a shortcut cancels the recording, not the editor.
|
||||
@@ -1524,6 +1549,18 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context
|
||||
/// menu); returns the chosen command and its params.
|
||||
pub(crate) fn menu_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, name: &str) -> Option<(String, Value)> {
|
||||
let mut clicked = None;
|
||||
if let Some(MenuNode::Submenu { children, .. }) =
|
||||
effectcraft_engine::menus::menu_bar().iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == name))
|
||||
{
|
||||
menu_nodes(app, ui, children, &mut clicked);
|
||||
}
|
||||
clicked.map(|(id, params)| (id, if params.is_null() { json!({}) } else { params }))
|
||||
}
|
||||
|
||||
fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], clicked: &mut Option<(String, Value)>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
|
||||
@@ -946,7 +946,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let selected = app.session.state.selected_props.iter().any(|(l, u)| *l == layer.id && *u == g.uid);
|
||||
bp.rect_filled(r, 0.0, if selected { Color32::from_rgb(0x2f, 0x3a, 0x52) } else { Color32::from_rgb(0x2a, 0x2a, 0x2a) });
|
||||
let fxr = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(16.0, 16.0));
|
||||
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), None).clicked() {
|
||||
if widgets::icon_toggle(ui, fxr, Icon::Fx, g.enabled, &t, egui::Id::new(("ec-fx", g.uid)), Sense::click()).clicked() {
|
||||
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": g.uid})));
|
||||
}
|
||||
app.auto.add(&format!("effectControls.effect.{}.fx", g.uid), fxr, &g.name);
|
||||
@@ -1063,6 +1063,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ctx.data_mut(|d| d.remove::<u64>(drag_id));
|
||||
}
|
||||
}
|
||||
// Right-click anywhere a control has no menu of its own: the Effect menu, which applies to
|
||||
// the selected layers (After Effects). Drawn after the rows, whose menus open first.
|
||||
let menu_id = bg.id.with("effect-menu");
|
||||
let open = ui.input(|i| i.pointer.secondary_clicked())
|
||||
&& ui.rect_contains_pointer(body)
|
||||
&& (!egui::Popup::is_any_open(&ctx) || egui::Popup::is_id_open(&ctx, menu_id));
|
||||
egui::Popup::context_menu(&bg).id(menu_id).open_memory(open.then_some(egui::SetOpenCommand::Bool(true))).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
actions.extend(crate::menus::menu_contents(app, ui, "Effect"));
|
||||
});
|
||||
ui.set_clip_rect(panel_clip);
|
||||
let content_h = y + scroll - body.min.y;
|
||||
scroll = scroll.min((content_h - body.height()).max(0.0));
|
||||
|
||||
@@ -83,6 +83,12 @@ pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
|
||||
effectcraft_engine::commands::time::display_time(session, comp, t)
|
||||
}
|
||||
|
||||
/// Spacebar held outside a text field: the Hand tool (drags pan the viewer and scroll the
|
||||
/// Timeline).
|
||||
pub(crate) fn space_hand(ctx: &egui::Context) -> bool {
|
||||
ctx.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input()
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {
|
||||
match p {
|
||||
PanelKind::Composition => viewers::show(app, ui, 0, rect),
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
//! search, item list with columns (Name, Label, then the visible optional columns: Type, Size,
|
||||
//! Media Duration, Frame Rate, File Path, Comment — shown or hidden from the header's context
|
||||
//! menu), folders (drag items into and out of them), renaming (Enter, or double-click the name),
|
||||
//! the label colour picker, and the bottom bar (interpret, new folder, new comp, bit depth,
|
||||
//! delete). Edits are `project.*` engine commands, so they are undoable.
|
||||
//! the label colour picker, and the bottom bar (interpret, new folder, new comp: click, or drop
|
||||
//! items on it for a comp from them, bit depth, delete). Edits are engine commands, so they are
|
||||
//! undoable.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -699,6 +700,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
_ => app.ui.status = "Interpret Footage: select a footage item".into(),
|
||||
}
|
||||
}
|
||||
// Items dropped on Create a new Composition: New Comp from Selection with them (After
|
||||
// Effects; several items ask how in its dialog).
|
||||
if id == "newComp"
|
||||
&& let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::<DragPayload>(&ctx).as_deref()
|
||||
&& ui.rect_contains_pointer(r)
|
||||
{
|
||||
p.rect_stroke(r, 4.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
if !app.session.state.project_selection.contains(&ItemId(*iid)) {
|
||||
app.session.state.project_selection = vec![ItemId(*iid)];
|
||||
}
|
||||
actions.push(("file.newCompFromSelection".into(), json!({})));
|
||||
egui::DragAndDrop::clear_payload(&ctx);
|
||||
}
|
||||
}
|
||||
x += 26.0;
|
||||
}
|
||||
let depth = app.session.project.settings.bit_depth.label();
|
||||
|
||||
@@ -862,6 +862,56 @@ struct KeyDrag {
|
||||
fn key_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-key-drag")
|
||||
}
|
||||
|
||||
/// A layer switch pressed and dragged over other layers: the switch, the state the press gave
|
||||
/// it, and the undo step the drag folds into.
|
||||
#[derive(Clone, Debug)]
|
||||
struct SwitchDrag {
|
||||
switch: &'static str,
|
||||
value: bool,
|
||||
merge: String,
|
||||
}
|
||||
|
||||
fn switch_drag_id() -> egui::Id {
|
||||
egui::Id::new("tl-switch-drag")
|
||||
}
|
||||
|
||||
/// A layer's switch (A/V Features and Switches columns) in `row`. Pressing it sets it, and
|
||||
/// dragging on gives the same switch of every layer the pointer passes the same new state, in
|
||||
/// one undo step (After Effects).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn layer_switch(
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
row: Rect,
|
||||
br: Rect,
|
||||
icon: Icon,
|
||||
on: bool,
|
||||
id: egui::Id,
|
||||
layer: u64,
|
||||
name: &'static str,
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
|
||||
let set = |actions: &mut Vec<(String, serde_json::Value)>, value: bool, merge: &str| {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge})))
|
||||
};
|
||||
if resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
|
||||
let merge = format!("tl-switch-{}", ui.input(|i| i.time));
|
||||
set(actions, !on, &merge);
|
||||
ui.data_mut(|d| d.insert_temp(switch_drag_id(), SwitchDrag { switch: name, value: !on, merge }));
|
||||
} else if let Some(d) = ui.data(|d| d.get_temp::<SwitchDrag>(switch_drag_id())).filter(|d| d.switch == name && d.value != on)
|
||||
// The heights the pointer moved over since the last frame, so a quick drag skips no row.
|
||||
&& let Some((a, b)) = ui.input(|i| i.pointer.interact_pos().map(|p| (p.y - i.pointer.delta().y, p.y)))
|
||||
&& a.min(b) < row.max.y
|
||||
&& a.max(b) >= row.min.y
|
||||
{
|
||||
set(actions, d.value, &d.merge);
|
||||
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
|
||||
// Keyboard or accessibility activation (a pointer press was handled above).
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name})));
|
||||
}
|
||||
}
|
||||
/// Set on the frame the reorder drag is released.
|
||||
/// Where layers dropped at height `py` land among the visible layer rows: above the first row
|
||||
/// whose middle is below it (its index in `layer_rows`), else below the last row. Returns that
|
||||
@@ -923,6 +973,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg));
|
||||
|
||||
let mut actions: Vec<(String, serde_json::Value)> = Vec::new();
|
||||
if !ui.input(|i| i.pointer.primary_down()) {
|
||||
ctx.data_mut(|d| d.remove::<SwitchDrag>(switch_drag_id()));
|
||||
}
|
||||
// ---- header (left): time display, search, switches.
|
||||
let tc = crate::panels::timecode(&app.session, &comp, time);
|
||||
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
|
||||
@@ -1339,11 +1392,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
lp.rect_filled(br.shrink(0.5), 1.0, SWITCH_BOX);
|
||||
let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("av", layer.id.0, i)), None);
|
||||
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, &mut actions);
|
||||
app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
|
||||
if resp.clicked() {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": name})));
|
||||
}
|
||||
}
|
||||
// Label swatch.
|
||||
if vis.label {
|
||||
@@ -1484,11 +1534,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0));
|
||||
lp.rect_filled(br.shrink(1.0), 1.0, SWITCH_BOX);
|
||||
let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("sw", layer.id.0, i)), None);
|
||||
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, &mut actions);
|
||||
app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
|
||||
if resp.clicked() {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": name})));
|
||||
}
|
||||
}
|
||||
// Modes.
|
||||
if app.ui.timeline.show_modes && layer.source.is_av() {
|
||||
@@ -1715,7 +1762,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
if let Some(en) = fx {
|
||||
let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
|
||||
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), None).clicked() {
|
||||
if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::new(("gfx", uid)), Sense::click()).clicked() {
|
||||
actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
|
||||
}
|
||||
app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
|
||||
@@ -1724,7 +1771,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Layer style eye switch in the A/V column, like AE.
|
||||
let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
|
||||
lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
|
||||
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), None).clicked() {
|
||||
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), Sense::click()).clicked() {
|
||||
actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
|
||||
}
|
||||
app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
|
||||
@@ -2403,6 +2450,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||||
super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
|
||||
|
||||
// Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when
|
||||
// zoomed in (registered last, so it takes the drag from the bars, keys and markers there).
|
||||
if super::space_hand(&ctx) {
|
||||
let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y));
|
||||
let hand = ui.interact(area, egui::Id::new("tl-hand"), Sense::drag());
|
||||
app.auto.add("timeline.hand", area, "Hand (hold Spacebar)");
|
||||
if hand.hovered() || hand.dragged() {
|
||||
ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
if hand.dragged() && app.ui.timeline.pps.is_some() {
|
||||
let start = app.ui.timeline.start;
|
||||
let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0);
|
||||
app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop targets: effects from Effects & Presets go on the layer under the pointer. Footage and
|
||||
// comps from the Project panel and files from the Media Browser go where they are dropped: a
|
||||
// line shows the place in the stack, and over the time graph a marker shows the In point too
|
||||
|
||||
@@ -991,7 +991,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
app.ui.viewer.interacting = resp.dragged();
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let space_pan = ui.input(|i| i.key_down(egui::Key::Space)) && !ctx.egui_wants_keyboard_input();
|
||||
let space_pan = super::space_hand(&ctx);
|
||||
// Pen tool: each press places a vertex (dragging pulls Bezier tangents); pressing on the
|
||||
// first vertex closes the path.
|
||||
if app.ui.tool == Tool::Pen
|
||||
|
||||
@@ -148,15 +148,15 @@ pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> R
|
||||
resp
|
||||
}
|
||||
|
||||
/// A flat icon toggle inside a rect.
|
||||
pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id: egui::Id, on_color: Option<Color32>) -> Response {
|
||||
let resp = ui.interact(rect, id, Sense::click());
|
||||
/// A flat icon toggle inside a rect (`sense`: click, or click and drag for a switch dragged
|
||||
/// over several rows).
|
||||
pub fn icon_toggle(ui: &mut Ui, rect: Rect, icon: Icon, on: bool, t: &Tokens, id: egui::Id, sense: Sense) -> Response {
|
||||
let resp = ui.interact(rect, id, sense);
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 2.0, t.hover);
|
||||
}
|
||||
let col = if on { on_color.unwrap_or(t.icon) } else { t.text_faint.gamma_multiply(0.7) };
|
||||
if on || resp.hovered() {
|
||||
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if !on { t.text_faint } else { col });
|
||||
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.18), icon, if on { t.icon } else { t.text_faint });
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
@@ -136,3 +136,32 @@ fn effects_dropped_on_the_viewer_go_on_the_layer_under_the_pointer() {
|
||||
let effects = |id: u64| comp.layer(effectcraft_engine::project::LayerId(id)).unwrap().effects().map_or(0, |fx| fx.groups().count());
|
||||
assert_eq!((effects(top), effects(middle)), (1, 0), "on Top, which is under the pointer");
|
||||
}
|
||||
|
||||
/// #227: Project items dropped on Create a new Composition (the Project panel's footer) make a
|
||||
/// composition from them, as File ▸ New Comp from Selection does; several items ask how in its
|
||||
/// dialog.
|
||||
#[test]
|
||||
fn project_items_dropped_on_new_comp_make_a_composition() {
|
||||
let (mut h, clip) = harness();
|
||||
let comps = |h: &Harness<'_, EffectcraftApp>| h.state().session.project.comps().count();
|
||||
let before = comps(&h);
|
||||
let item = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
let button = rect(&h, "project.newComp").center();
|
||||
drag(&mut h, item, button, Modifiers::NONE);
|
||||
assert_eq!(comps(&h), before + 1);
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
assert_eq!((comp.width, comp.height, comp.duration.seconds()), (160, 90, 1.0), "the item's settings");
|
||||
assert_eq!(stack(&h), ["Clip"], "holding the item");
|
||||
assert_ne!(h.state().session.active_comp_id().map(|c| c.0), Some(clip), "a new comp, open");
|
||||
assert!(h.state().dialog.is_none());
|
||||
|
||||
// Two selected items, one dragged: New Composition from Selection asks how.
|
||||
let main = h.state().session.project.items.values().find(|i| i.name == "Main").unwrap().id;
|
||||
h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip), main];
|
||||
h.run_steps(2);
|
||||
let item = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
drag(&mut h, item, button, Modifiers::NONE);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Form));
|
||||
assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog");
|
||||
assert_eq!(comps(&h), before + 1, "nothing made yet");
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::PanelKind;
|
||||
use effectcraft_ui_egui::state::FxPick;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
struct Ids {
|
||||
@@ -269,3 +270,68 @@ fn angle_revolutions_scrub_and_take_typing() {
|
||||
type_into(&mut h, &format!("properties.prop.{rot}.revolutions"), "1");
|
||||
assert_eq!(rotation(&h), 360.0);
|
||||
}
|
||||
|
||||
fn right_click(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) {
|
||||
h.event(egui::Event::PointerMoved(pos));
|
||||
h.step();
|
||||
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.event(egui::Event::PointerButton { pos, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn hover(h: &mut Harness<'_, EffectcraftApp>, pos: egui::Pos2) {
|
||||
h.event(egui::Event::PointerMoved(pos));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// #227: right-clicking Effect Controls where no control has a menu of its own shows the
|
||||
/// Effect menu (After Effects), which applies an effect to the selected layers; an effect's
|
||||
/// header keeps its own menu.
|
||||
#[test]
|
||||
fn right_click_in_effect_controls_shows_the_effect_menu() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let a = s.execute("layer.newSolid", json!({"name": "A", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"name": "B", "color": "#804060"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let fill = s.execute("effect.apply", json!({"layers": [b], "effect": "Fill"})).unwrap()["effects"][0].as_u64().unwrap();
|
||||
s.state.selected_layers = vec![LayerId(b), LayerId(a)];
|
||||
let mut app = EffectcraftApp::new(s);
|
||||
app.show_panel(PanelKind::EffectControls);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app);
|
||||
h.run_steps(3);
|
||||
let category = " Blur & Sharpen ⏵";
|
||||
// An effect's header: its own menu.
|
||||
let header = rect_of(&h, &format!("effectControls.effect.{fill}")).center();
|
||||
right_click(&mut h, header);
|
||||
assert!(h.query_by_label("Duplicate").is_some(), "the effect's own menu");
|
||||
assert!(h.query_by_label(category).is_none());
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
// A control without a menu (the header's Reset): the Effect menu.
|
||||
let reset = rect_of(&h, &format!("effectControls.effect.{fill}.reset")).center();
|
||||
right_click(&mut h, reset);
|
||||
assert!(h.query_by_label(category).is_some());
|
||||
assert_eq!(layer(&h.state().session, b).effects().map(|f| f.groups().count()), Some(1), "Reset didn't run");
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label(category).is_none());
|
||||
// The empty area below it: the Effect menu.
|
||||
let panel = rect_of(&h, "panel.EffectControls");
|
||||
right_click(&mut h, egui::pos2(panel.center().x, panel.max.y - 30.0));
|
||||
let cat = h.query_by_label(category).expect("the Effect menu's categories").rect();
|
||||
assert!(h.query_by_label_contains("Remove All").is_some(), "the whole Effect menu");
|
||||
hover(&mut h, cat.center());
|
||||
let at = h.query_by_label(" Gaussian Blur").expect("Blur & Sharpen ▸ Gaussian Blur").rect().center();
|
||||
hover(&mut h, egui::pos2(at.x, cat.center().y));
|
||||
for k in 1..=10 {
|
||||
hover(&mut h, egui::pos2(at.x, cat.center().y + (at.y - cat.center().y) * k as f32 / 10.0));
|
||||
}
|
||||
click(&mut h, at);
|
||||
let names = |h: &Harness<'_, EffectcraftApp>, l: u64| -> Vec<String> {
|
||||
layer(&h.state().session, l).effects().map(|f| f.groups().map(|g| g.name.clone()).collect()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(names(&h, b), ["Fill", "Gaussian Blur"]);
|
||||
assert_eq!(names(&h, a), ["Gaussian Blur"], "every selected layer gets it");
|
||||
assert!(h.query_by_label(category).is_none(), "the menu closed");
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Timeline layer switches with real pointer input (egui_kittest): pressing a switch and
|
||||
//! dragging over other layers gives them all the state the first one got, in one undo step, as
|
||||
//! in After Effects (#227).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let mut ids: Vec<u64> = ["D", "C", "B", "A"]
|
||||
.iter()
|
||||
.map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap())
|
||||
.collect();
|
||||
ids.reverse();
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
/// Press on `from`, move to `to` in `steps`, release there.
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=steps {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Each layer's switch, top to bottom.
|
||||
fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&effectcraft_engine::project::Layer) -> bool) -> Vec<bool> {
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect()
|
||||
}
|
||||
|
||||
fn video(l: &effectcraft_engine::project::Layer) -> bool {
|
||||
l.switches.video
|
||||
}
|
||||
|
||||
fn motion_blur(l: &effectcraft_engine::project::Layer) -> bool {
|
||||
l.switches.motion_blur
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_over_layer_switches_sets_them_all() {
|
||||
let (mut h, ids) = harness();
|
||||
let eye = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.video", ids[i])).center();
|
||||
let steps = h.state().session.history.undo.len();
|
||||
|
||||
// From A's eye down to D's: all hidden, one undo step.
|
||||
let (a, d) = (eye(&h, 0), eye(&h, 3));
|
||||
drag(&mut h, a, d, 10);
|
||||
assert_eq!(states(&h, &ids, video), [false; 4]);
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(states(&h, &ids, video), [true; 4]);
|
||||
|
||||
// Every layer gets the first one's new state, whatever its own: B hidden, then from C (shown
|
||||
// → hidden) up to A, quickly (two moves): B stays hidden, A is hidden, D untouched.
|
||||
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[1]], "switch": "video", "value": false})).unwrap();
|
||||
h.run_steps(2);
|
||||
let (c, a) = (eye(&h, 2), eye(&h, 0));
|
||||
drag(&mut h, c, a, 2);
|
||||
assert_eq!(states(&h, &ids, video), [false, false, false, true]);
|
||||
// From B (hidden → shown) down to D: B, C and D shown.
|
||||
let (b, d) = (eye(&h, 1), eye(&h, 3));
|
||||
drag(&mut h, b, d + vec2(0.0, 40.0), 6);
|
||||
assert_eq!(states(&h, &ids, video), [false, true, true, true]);
|
||||
|
||||
// A click still toggles only that layer.
|
||||
let c = eye(&h, 2);
|
||||
drag(&mut h, c, c, 1);
|
||||
assert_eq!(states(&h, &ids, video), [false, true, false, true]);
|
||||
|
||||
// The Switches column works the same (Motion Blur, from D up to B).
|
||||
let mb = |h: &Harness<'_, EffectcraftApp>, i: usize| rect(h, &format!("timeline.layer.{}.switch.motionBlur", ids[i])).center();
|
||||
let (d, b) = (mb(&h, 3), mb(&h, 1));
|
||||
drag(&mut h, d, b, 8);
|
||||
assert_eq!(states(&h, &ids, motion_blur), [false, true, true, true]);
|
||||
assert_eq!(states(&h, &ids, video), [false, true, false, true], "other switches untouched");
|
||||
}
|
||||
@@ -124,3 +124,40 @@ fn semicolon_toggles_frame_level_and_the_whole_comp() {
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap();
|
||||
assert_eq!(h.state().ui.timeline.pps, None);
|
||||
}
|
||||
|
||||
fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) {
|
||||
h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// #227: with Spacebar held, a drag over the time graph scrolls it in time (the Hand tool)
|
||||
/// instead of moving the layer bar under the pointer, and the release doesn't preview.
|
||||
#[test]
|
||||
fn spacebar_drag_scrolls_the_time_graph() {
|
||||
let (mut h, layer) = harness();
|
||||
// (Opening the comp showed all of it.)
|
||||
h.state_mut().ui.timeline.pps = Some(200.0);
|
||||
h.state_mut().ui.timeline.start = 10.0;
|
||||
h.run_steps(2);
|
||||
let in_point = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers[0].in_point.seconds();
|
||||
let from = pos2(rect(&h, "timeline.ruler").center().x, over_bar(&h, layer).y);
|
||||
// 400 px to the left at 200 px/s: 2 s later.
|
||||
space(&mut h, true);
|
||||
drag(&mut h, from, from - vec2(400.0, 0.0));
|
||||
space(&mut h, false);
|
||||
let (a, _) = visible(&h);
|
||||
assert!((a - 12.0).abs() < 0.01, "starts at {a}");
|
||||
assert_eq!(in_point(&h), 0.0, "the bar didn't move");
|
||||
assert!(!h.state().playback.playing, "a Spacebar drag doesn't preview");
|
||||
// To the right, at most back to the comp's start.
|
||||
space(&mut h, true);
|
||||
for _ in 0..6 {
|
||||
drag(&mut h, from, from + vec2(500.0, 0.0));
|
||||
}
|
||||
space(&mut h, false);
|
||||
assert_eq!(visible(&h).0, 0.0);
|
||||
assert_eq!(in_point(&h), 0.0);
|
||||
// Without Spacebar the same drag moves the bar.
|
||||
drag(&mut h, from, from - vec2(400.0, 0.0));
|
||||
assert!(in_point(&h) < -1.0, "{}", in_point(&h));
|
||||
}
|
||||
@@ -761,6 +761,67 @@ fn middle_and_hand_drags_pan_the_viewer_and_the_pan_stays_after_release() {
|
||||
assert!((end[0] - after[0] + 50.0).abs() < 1.0 && (end[1] - after[1] + 30.0).abs() < 1.0, "{after:?} → {end:?}");
|
||||
}
|
||||
|
||||
/// A Spacebar press or release (`repeat`: the keyboard's auto-repeat while it is held).
|
||||
fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool, repeat: bool) {
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat, modifiers: Default::default() });
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// #227: a Spacebar tap starts and stops the preview when it is released; held, Spacebar is the
|
||||
/// Hand tool, so a drag pans the viewer and neither starts nor stops the preview. Auto-repeat
|
||||
/// while it is held toggles nothing more, and a space typed in a text field doesn't preview.
|
||||
#[test]
|
||||
fn spacebar_taps_preview_and_held_spacebar_pans_the_viewer() {
|
||||
let mut h = harness();
|
||||
let playing = |h: &Harness<'_, EffectcraftApp>| h.state().playback.playing;
|
||||
let tap = |h: &mut Harness<'_, EffectcraftApp>| {
|
||||
space(h, true, false);
|
||||
space(h, false, false);
|
||||
};
|
||||
space(&mut h, true, false);
|
||||
assert!(!playing(&h), "the press alone doesn't play");
|
||||
space(&mut h, false, false);
|
||||
assert!(playing(&h), "the release does");
|
||||
tap(&mut h);
|
||||
assert!(!playing(&h), "another tap stops");
|
||||
// Held with auto-repeat: one toggle, on the release.
|
||||
space(&mut h, true, false);
|
||||
for _ in 0..5 {
|
||||
space(&mut h, true, true);
|
||||
}
|
||||
assert!(!playing(&h));
|
||||
space(&mut h, false, false);
|
||||
assert!(playing(&h));
|
||||
tap(&mut h);
|
||||
|
||||
// Held and dragged: pans the viewer (the Box under the pointer stays), and plays nothing.
|
||||
let box_pos = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layers[0].props.prop("transform/position").unwrap().value.clone();
|
||||
let (pos, before) = (box_pos(&h), h.state().ui.viewer.pan);
|
||||
let c = rect(&h, "viewer.comp").center();
|
||||
space(&mut h, true, false);
|
||||
drag_with(&mut h, egui::PointerButton::Primary, c, c + vec2(80.0, 40.0));
|
||||
space(&mut h, false, false);
|
||||
let after = h.state().ui.viewer.pan;
|
||||
assert!((after[0] - before[0] - 80.0).abs() < 1.0 && (after[1] - before[1] - 40.0).abs() < 1.0, "{before:?} → {after:?}");
|
||||
assert_eq!(box_pos(&h), pos);
|
||||
assert!(!playing(&h), "a Spacebar drag doesn't start the preview");
|
||||
// Nor stop one.
|
||||
tap(&mut h);
|
||||
let c = rect(&h, "viewer.comp").center();
|
||||
space(&mut h, true, false);
|
||||
drag_with(&mut h, egui::PointerButton::Primary, c, c - vec2(30.0, 0.0));
|
||||
space(&mut h, false, false);
|
||||
assert!(playing(&h), "a Spacebar drag doesn't stop the preview");
|
||||
tap(&mut h);
|
||||
assert!(!playing(&h));
|
||||
|
||||
// In a text field Spacebar types.
|
||||
let search = rect(&h, "timeline.search").center();
|
||||
click(&mut h, search);
|
||||
tap(&mut h);
|
||||
assert!(!playing(&h), "a space typed in the Timeline search doesn't preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_rows_drag_to_reorder_layers() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -98,6 +98,12 @@ The most important missing piece: it turns every other estimate here into a meas
|
||||
and Change Renderer, #206; deselecting below the layers, Alt+Shift+P and the toolbar's stroke,
|
||||
#205; linked Mask Feather, #203), `dragging_in_the_empty_area_box_selects_items` and
|
||||
`selecting_a_mask_selects_its_points` (#203), and `input_keys_ignore_later_effects` (#212).
|
||||
- Regression evidence for the interface items of [#227](https://github.com/storytold/effectcraft/issues/227),
|
||||
checked headlessly on Windows: `spacebar_taps_preview_and_held_spacebar_pans_the_viewer` and
|
||||
`spacebar_drag_scrolls_the_time_graph` (Spacebar previews on its release, held it is the Hand
|
||||
tool), `right_click_in_effect_controls_shows_the_effect_menu`,
|
||||
`project_items_dropped_on_new_comp_make_a_composition` and
|
||||
`dragging_over_layer_switches_sets_them_all`.
|
||||
- Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on
|
||||
any of the three desktop platforms, and every bug users report gets triaged within a day.
|
||||
|
||||
|
||||
Reference in new issue
Block a user