mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Shapes and timeline: toolbar Fill / Stroke edit selected shape layers; Alt+Shift+P reveals; click below layers deselects (#205)
- With a shape layer selected, the toolbar shows its Fill, Stroke and Stroke Width (with any tool) and editing them changes the layer's fills and strokes, as in After Effects (new shape.fillStroke, one undo step per picker or drag). Shapes drawn without a stroke get one when a stroke colour or width is set, so changing the stroke now works. - Alt+Shift+P (A, S, R, T) reveals the property it keyed on each layer. - A click in the time graph below the last layer deselects the layers (it only deselected keyframes), so a shape tool draws a new shape layer instead of a mask on the selected image. Tests: toolbar_fill_and_stroke_paint_selected_shape_layers, toolbar_stroke_edits_the_selected_shape_layer, add_position_key_reveals_position, clicking_below_the_layers_deselects_them. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
This commit is contained in:
7 files changed
+280
-12
No files matched your search
@@ -54,7 +54,7 @@ pub mod roto_cmds;
|
||||
mod scene_detect;
|
||||
pub mod scripts;
|
||||
mod settings;
|
||||
mod shape_stroke;
|
||||
pub mod shape_stroke;
|
||||
mod stubs;
|
||||
mod styles;
|
||||
mod text_anim;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
//! Shape Stroke options: the Dashes "+" / "−" buttons (Dash 2 / Gap 2, Dash 3 / Gap 3), Taper and
|
||||
//! Wave. Taper and Wave are ordinary properties of the stroke (`…/stroke/taper/startLength`); the
|
||||
//! commands here set several of them in one undo step and add the groups to strokes saved before
|
||||
//! they existed.
|
||||
//! they existed. Also the toolbar's Fill / Stroke options on selected shape layers.
|
||||
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, EXTRA_DASHES, Ids};
|
||||
use effectcraft_project::{ItemId, LayerId, PropGroup, Uid};
|
||||
use effectcraft_project::{ItemId, Layer, LayerId, LayerSource, Node, PropGroup, Uid};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_layers, layer_mut, layer_p, merge_p};
|
||||
use super::layer::color_p;
|
||||
use super::{CommandSpec, bad, f_p, has_layers, layer_mut, layer_p, layers_p, merge_p};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn is_stroke(g: &PropGroup) -> bool {
|
||||
@@ -195,8 +197,116 @@ fn wave(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Path items a Fill or Stroke paints in their group.
|
||||
const PATHS: [&str; 4] = ["rect", "ellipse", "star", "path"];
|
||||
|
||||
/// Shape groups nest; deeper contents are left alone (never-crash recursion bound).
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The first Fill colour, Stroke colour and Stroke Width in `layer`'s contents at layer time
|
||||
/// `lt` (what the toolbar shows for a selected shape layer).
|
||||
pub fn first_paint(layer: &Layer, lt: Tick) -> (Option<[f64; 4]>, Option<[f64; 4]>, Option<f64>) {
|
||||
let (mut fill, mut stroke, mut width) = (None, None, None);
|
||||
if let Some(c) = layer.props.sub("contents") {
|
||||
walk_groups(c, &mut |g| {
|
||||
let at = |id: &str| g.get(id).map(|p| p.value_at(lt));
|
||||
let rgba = |v: KV| v.as_color().map(f64::from);
|
||||
match g.match_id.as_str() {
|
||||
"fill" if fill.is_none() => fill = at("color").map(rgba),
|
||||
"stroke" if stroke.is_none() => {
|
||||
stroke = at("color").map(rgba);
|
||||
width = at("width").map(|v| v.as_f64());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
(fill, stroke, width)
|
||||
}
|
||||
|
||||
/// Paint one contents group and its sub-groups: Fills take `fill`, Strokes `stroke`, Strokes and
|
||||
/// Gradient Strokes `width`. With `add_stroke`, a group with a path and no stroke gets one (before
|
||||
/// its fills, as new shapes have it).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint(c: &mut PropGroup, ids: &mut Ids, lt: Tick, fill: Option<[f64; 4]>, stroke: Option<[f64; 4]>, width: Option<f64>, add_stroke: bool, depth: usize) {
|
||||
if depth > MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
if add_stroke && c.groups().any(|g| PATHS.contains(&g.match_id.as_str())) && !c.groups().any(is_stroke) {
|
||||
let at = c.children.iter().position(|n| matches!(n, Node::Group(g) if matches!(g.match_id.as_str(), "fill" | "gfill"))).unwrap_or(c.children.len());
|
||||
c.children.insert(at, build::shape_stroke(ids, stroke.unwrap_or([1.0; 4]), width.unwrap_or(2.0)).into());
|
||||
}
|
||||
for n in &mut c.children {
|
||||
let Node::Group(g) = n else { continue };
|
||||
let set = |g: &mut PropGroup, id: &str, v: Option<KV>| {
|
||||
if let (Some(v), Some(p)) = (v, g.get_mut(id)) {
|
||||
p.set_value_at(lt, v);
|
||||
}
|
||||
};
|
||||
match g.match_id.as_str() {
|
||||
"fill" => set(g, "color", fill.map(KV::Color)),
|
||||
"stroke" => {
|
||||
set(g, "color", stroke.map(KV::Color));
|
||||
set(g, "width", width.map(KV::Scalar));
|
||||
}
|
||||
"gstroke" => set(g, "width", width.map(KV::Scalar)),
|
||||
"group" => {
|
||||
if let Some(sub) = g.sub_mut("contents") {
|
||||
paint(sub, ids, lt, fill, stroke, width, add_stroke, depth + 1);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The toolbar's Fill and Stroke options with shape layers selected (as in After Effects, they
|
||||
/// change the layers' fills and strokes): `fill` sets every Fill's colour, `stroke` every Stroke's,
|
||||
/// `strokeWidth` every stroke's width. A stroke colour or a width above 0 gives the paths that
|
||||
/// have no stroke one. Without values, reports the first layer's paint.
|
||||
fn fill_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "shape.fillStroke";
|
||||
let (cid, lids) = layers_p(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let shapes: Vec<LayerId> = lids.into_iter().filter(|l| comp.layer(*l).is_some_and(|l| matches!(l.source, LayerSource::Shape))).collect();
|
||||
if shapes.is_empty() {
|
||||
return Err(bad(c, "select a shape layer"));
|
||||
}
|
||||
let rgba = |k: &str| color_p(p, k).map(|c| [c[0] as f64, c[1] as f64, c[2] as f64, 1.0]);
|
||||
let (fill, stroke) = (rgba("fill"), rgba("stroke"));
|
||||
let width = f_p(p, "strokeWidth").filter(|w| w.is_finite()).map(|w| w.max(0.0));
|
||||
if fill.is_some() || stroke.is_some() || width.is_some() {
|
||||
let add_stroke = width.map_or(stroke.is_some(), |w| w > 0.0);
|
||||
let t = s.time();
|
||||
s.edit("Fill and Stroke", merge_p(p), |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for lid in &shapes {
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
if let Some(contents) = l.props.sub_mut("contents") {
|
||||
paint(contents, &mut Ids(&mut next), lt, fill, stroke, width, add_stroke, 0);
|
||||
}
|
||||
}
|
||||
proj.next_id = next;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let l = shapes.first().and_then(|id| s.project.comp(cid)?.layer(*id)).ok_or(EngineError::NoComp)?;
|
||||
let (fill, stroke, width) = first_paint(l, l.layer_time(s.time()));
|
||||
Ok(json!({"fill": fill, "stroke": stroke, "strokeWidth": width}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"shape.fillStroke",
|
||||
"Fill and Stroke",
|
||||
[],
|
||||
None,
|
||||
"{layers?, fill?: color, stroke?: color, strokeWidth?, merge?} → {fill, stroke, strokeWidth} (first layer)",
|
||||
has_layers,
|
||||
fill_stroke
|
||||
),
|
||||
cmd!("shape.dashes.add", "Add Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_add),
|
||||
cmd!("shape.dashes.remove", "Remove Dash or Gap", [], None, "{layer?, prop?: stroke uid}", has_layers, dashes_remove),
|
||||
cmd!(
|
||||
|
||||
@@ -183,6 +183,40 @@ fn text_shape_mask_matte_parent() {
|
||||
assert_eq!(info["layers"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// The toolbar's Fill / Stroke change a selected shape layer's paint (#205): a shape drawn
|
||||
/// without a stroke gets one, later edits change it, and the Contents "Add:" items (#206) include
|
||||
/// an empty Path and a Gradient Stroke that render.
|
||||
#[test]
|
||||
fn toolbar_fill_and_stroke_paint_selected_shape_layers() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"width": 400, "height": 300, "duration": 2})).unwrap();
|
||||
let sh = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let paint = |s: &mut Session| s.execute("shape.fillStroke", json!({})).unwrap();
|
||||
assert_eq!(paint(&mut s)["stroke"], serde_json::Value::Null, "drawn without a stroke");
|
||||
s.execute("shape.fillStroke", json!({"stroke": "#00ff00", "strokeWidth": 6})).unwrap();
|
||||
let p = paint(&mut s);
|
||||
assert_eq!((p["stroke"][1].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(6.0)), "{p}");
|
||||
s.execute("shape.fillStroke", json!({"fill": "#0000ff", "strokeWidth": 3})).unwrap();
|
||||
let p = paint(&mut s);
|
||||
assert_eq!((p["fill"][2].as_f64(), p["strokeWidth"].as_f64()), (Some(1.0), Some(3.0)), "{p}");
|
||||
// One stroke, before the fill (paths, stroke, fill), and each change is one undo step.
|
||||
let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(sh)).unwrap().clone();
|
||||
let group = l.props.group("contents/group").unwrap();
|
||||
let order: Vec<&str> = group.sub("contents").unwrap().groups().map(|g| g.match_id.as_str()).collect();
|
||||
assert_eq!(order, ["rect", "stroke", "fill"]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(paint(&mut s)["strokeWidth"].as_f64(), Some(6.0));
|
||||
// Only shape layers.
|
||||
s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap();
|
||||
assert!(s.execute("shape.fillStroke", json!({"fill": "#ffffff"})).is_err());
|
||||
for kind in ["path", "gstroke"] {
|
||||
let r = s.execute("layer.addShapeItem", json!({"layer": sh, "kind": kind})).unwrap();
|
||||
assert!(r["path"].as_str().is_some_and(|p| p.starts_with("contents/")), "{r}");
|
||||
}
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let _ = s.render(cid, s.time(), Default::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_open_roundtrip() {
|
||||
let mut s = demo();
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::state::Tool;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
use effectcraft_engine::project::{LayerId, LayerSource};
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
@@ -91,7 +92,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
|
||||
// Tool options.
|
||||
x += 10.0;
|
||||
if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen {
|
||||
// Selected shape layers: the options show their paint and edit it (as in After Effects).
|
||||
let shape_layers: 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();
|
||||
if let Some(l) = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))) {
|
||||
let (fill, stroke, width) = effectcraft_engine::commands::shape_stroke::first_paint(l, l.layer_time(app.session.time()));
|
||||
let rgb = |c: [f64; 4]| [c[0] as f32, c[1] as f32, c[2] as f32];
|
||||
if let Some(c) = fill {
|
||||
app.ui.fill_color = rgb(c);
|
||||
}
|
||||
if let Some(c) = stroke {
|
||||
app.ui.stroke_color = rgb(c);
|
||||
}
|
||||
app.ui.stroke_width = width.unwrap_or(0.0) as f32;
|
||||
}
|
||||
// One undo step per picker session or width drag: end the merge once both are done.
|
||||
let picking = ["tool-fill-pop", "tool-stroke-pop"].iter().any(|k| ui.data(|d| d.get_temp::<bool>(egui::Id::new(*k).with("open")).unwrap_or(false)));
|
||||
if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) {
|
||||
app.session.history.merge_key = None;
|
||||
}
|
||||
if app.ui.tool.is_shape() || app.ui.tool == Tool::Pen || !shape_layers.is_empty() {
|
||||
let paint = |app: &mut EffectcraftApp, key: &str, v: serde_json::Value| {
|
||||
if shape_layers.is_empty() {
|
||||
return;
|
||||
}
|
||||
let merge = format!("tool-{key}");
|
||||
if let Err(e) = app.session.execute("shape.fillStroke", serde_json::json!({"layers": shape_layers, key: v, "merge": merge})) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
};
|
||||
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Fill:", Tokens::ui(12.0), t.text_dim);
|
||||
x += 28.0;
|
||||
let fr = Rect::from_center_size(pos2(x + 10.0, cy), vec2(20.0, 16.0));
|
||||
@@ -99,7 +133,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if widgets::swatch(ui, fr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-fill"), &t).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("tool-fill-pop"));
|
||||
}
|
||||
color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color);
|
||||
if color_popup(ui, egui::Id::new("tool-fill-pop"), fr.left_bottom(), &mut app.ui.fill_color) {
|
||||
let c = app.ui.fill_color;
|
||||
paint(app, "fill", serde_json::json!(c));
|
||||
}
|
||||
app.auto.add("header.fill", fr, "Fill Color");
|
||||
x += 32.0;
|
||||
p.text(pos2(x, cy), Align2::LEFT_CENTER, "Stroke:", Tokens::ui(12.0), t.text_dim);
|
||||
x += 44.0;
|
||||
@@ -108,13 +146,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if widgets::swatch(ui, sr, [c[0], c[1], c[2], 1.0], egui::Id::new("tool-stroke"), &t).clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("tool-stroke-pop"));
|
||||
}
|
||||
color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color);
|
||||
if color_popup(ui, egui::Id::new("tool-stroke-pop"), sr.left_bottom(), &mut app.ui.stroke_color) {
|
||||
let c = app.ui.stroke_color;
|
||||
paint(app, "stroke", serde_json::json!(c));
|
||||
}
|
||||
app.auto.add("header.stroke", sr, "Stroke Color");
|
||||
x += 26.0;
|
||||
let (r, v, _) =
|
||||
widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::new("tool-stroke-w"), app.ui.stroke_width as f64, 0.2, (0.0, 1000.0), 0, " px", &t);
|
||||
if let Some(v) = v {
|
||||
app.ui.stroke_width = v as f32;
|
||||
paint(app, "strokeWidth", serde_json::json!(v));
|
||||
}
|
||||
app.auto.add("header.strokeWidth", r, "Stroke Width");
|
||||
x = r.max.x + 14.0;
|
||||
}
|
||||
if app.ui.tool.puppet_kind().is_some() {
|
||||
|
||||
@@ -190,6 +190,35 @@ fn reveal_time_remap(app: &mut EffectcraftApp, params: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// After Alt+Shift+P (A, S, R, T) the Timeline shows the property on each layer it keyed, as in
|
||||
/// After Effects: added to what a reveal shortcut shows, else with the layer's Transform twirled
|
||||
/// open, else alone like P.
|
||||
fn reveal_keyed(app: &mut EffectcraftApp, keyed: &Value, kind: &str) {
|
||||
let layers: std::collections::BTreeSet<u64> = keyed.as_array().into_iter().flatten().filter_map(|k| k.get("layer").and_then(Value::as_u64)).collect();
|
||||
let Some(comp) = app.session.active_comp() else { return };
|
||||
let tl = &mut app.ui.timeline;
|
||||
for id in layers {
|
||||
let shown = tl.layer_reveal.get(&id).filter(|k| !k.is_empty()).cloned();
|
||||
match shown {
|
||||
Some(mut kinds) => {
|
||||
if !kinds.iter().any(|k| k == kind) {
|
||||
kinds.push(kind.to_string());
|
||||
tl.layer_reveal.insert(id, kinds);
|
||||
}
|
||||
}
|
||||
None if tl.open_layers.contains(&id) => {
|
||||
if let Some(tr) = comp.layer(effectcraft_engine::project::LayerId(id)).and_then(|l| l.transform()) {
|
||||
tl.open_groups.insert(tr.uid);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tl.open_layers.insert(id);
|
||||
tl.layer_reveal.insert(id, vec![kind.to_string()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers a reveal shortcut acts on: the selected ones, else every layer of the active comp.
|
||||
fn reveal_targets(app: &EffectcraftApp) -> Vec<u64> {
|
||||
if app.session.state.selected_layers.is_empty() {
|
||||
@@ -314,7 +343,9 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.keyAt.") {
|
||||
return run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}));
|
||||
let r = run_engine(app, ctx, "keys.toggleTransform", json!({"prop": k}))?;
|
||||
reveal_keyed(app, &r, k);
|
||||
return Ok(r);
|
||||
}
|
||||
if let Some(k) = id.strip_prefix("timeline.revealAdd.") {
|
||||
reveal(app, k, now, true);
|
||||
|
||||
@@ -2215,9 +2215,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if graph_on {
|
||||
super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
|
||||
}
|
||||
// Empty-area click in the graph: deselect keys; drag: box-select keys.
|
||||
// Empty-area click in the graph: deselect keys (below the last row, the layers too, as in
|
||||
// After Effects); drag: box-select keys.
|
||||
if empty.clicked() {
|
||||
actions.push(("keys.select".into(), json!({"keys": []})));
|
||||
let below = empty.interact_pointer_pos().is_some_and(|p| hit_rows.iter().all(|(r, _)| p.y > r.max.y));
|
||||
if below && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
|
||||
actions.push(("layer.select".into(), json!({"layers": []})));
|
||||
}
|
||||
}
|
||||
if let (true, Some(origin), Some(cur)) =
|
||||
(empty.dragged(), empty.interact_pointer_pos().and_then(|_| ctx.input(|i| i.pointer.press_origin())), empty.interact_pointer_pos())
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Timeline rows for shape layers: the Contents "Add:" menu and the Classic 3D "Change
|
||||
//! Renderer…" row (#206).
|
||||
//! Timeline rows for shape layers and keyframing: the Contents "Add:" menu and the Classic 3D
|
||||
//! "Change Renderer…" row (#206), Alt+Shift+P revealing the key it adds, clicks below the layers
|
||||
//! deselecting them and the toolbar's Stroke on a selected shape layer (#205).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::LayerId;
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, pos2};
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
@@ -78,3 +79,46 @@ fn classic_3d_shape_layers_offer_change_renderer() {
|
||||
assert!(h.state().auto.find(&format!("timeline.layer.{l}.changeRenderer")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_position_key_reveals_position() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(s);
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.keyAt.position", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
let position = app.session.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/position").unwrap().clone();
|
||||
assert_eq!(position.keys.len(), 1);
|
||||
assert_eq!(app.ui.timeline.layer_reveal.get(&l), Some(&vec!["position".to_string()]));
|
||||
assert!(app.auto.find(&format!("timeline.prop.{}.value.0", position.uid)).is_some(), "the Position row shows");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_below_the_layers_deselects_them() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(s);
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(l)]);
|
||||
// In the time graph, below the only layer.
|
||||
let bar = center(&h, &format!("timeline.layer.{l}.bar"));
|
||||
click_at(&mut h, bar + egui::vec2(0.0, 80.0));
|
||||
assert!(h.state().session.state.selected_layers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toolbar_stroke_edits_the_selected_shape_layer() {
|
||||
let mut s = session();
|
||||
let l = s.execute("layer.newShape", json!({"kind": "rect", "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let mut h = harness(s);
|
||||
// The Selection tool is active: the options show for the selected shape layer.
|
||||
click(&mut h, "header.strokeWidth");
|
||||
h.input_mut().events.push(Event::Text("4".into()));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
let layer = h.state().session.active_comp().unwrap().layer(LayerId(l)).unwrap().clone();
|
||||
let width = layer.props.prop("contents/group/contents/stroke/width").map(|p| p.value.as_f64());
|
||||
assert_eq!(width, Some(4.0), "a stroke was added with the toolbar's width");
|
||||
assert_eq!(h.state().ui.stroke_width, 4.0);
|
||||
}
|
||||
Reference in new issue
Block a user