mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M13.5: timeline/project column scrolling, keyframe label groups
Interface: Timeline outline columns wider than the pane scroll horizontally (Shift+wheel/trackpad, scroll bar above the footer; headers clipped); the Project panel's optional columns scroll under the frozen Name/Label columns. ANM-1: keyframe colour labels (keys.setLabel, Label submenu, tinted key icons) and Edit > Select Keyframe Label Group on selected/all layers and visible keyframes (keys.selectLabelGroup replaces the stub). Alt-drag time scaling of key groups already existed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
85c645f337
commit
b03af6a979
9 files changed
+327
-11
No files matched your search
@@ -0,0 +1,85 @@
|
||||
//! Keyframe color labels: Label a keyframe (Keyframe ▸ Label ▸ colour) and Edit ▸ Select Keyframe
|
||||
//! Label Group (On Selected Layers / On All Layers / Visible Keyframes on …).
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, has_comp, has_keys, str_p};
|
||||
use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
fn label_p(p: &Value, c: &str) -> Result<u8> {
|
||||
match p.get("label") {
|
||||
Some(Value::String(s)) => {
|
||||
let l = if s.eq_ignore_ascii_case("none") { Some(Label::None) } else { Label::from_name(s) };
|
||||
let l = l.ok_or_else(|| bad(c, format!("unknown label `{s}`")))?;
|
||||
Ok(Label::ALL.iter().position(|x| *x == l).unwrap_or(0) as u8)
|
||||
}
|
||||
Some(Value::Number(n)) => Ok(n.as_u64().filter(|i| (*i as usize) < Label::ALL.len()).ok_or_else(|| bad(c, "label index 0–16"))? as u8),
|
||||
_ => Err(bad(c, "missing `label` (name or 0–16)")),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_label(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "keys.setLabel";
|
||||
let label = label_p(p, c)?;
|
||||
let n = super::prop::edit_keys(s, "Keyframe Label", None, |keys, i, _| {
|
||||
keys[i].label = label;
|
||||
true
|
||||
})?;
|
||||
Ok(json!({"label": Label::ALL[label as usize].name(), "keys": n}))
|
||||
}
|
||||
|
||||
/// Select every keyframe that shares a label with a selected keyframe, on the selected or all
|
||||
/// layers. `visible` (property uids revealed in the Timeline) limits the "Visible Keyframes"
|
||||
/// scopes; without it every property counts as visible.
|
||||
fn select_label_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "keys.selectLabelGroup";
|
||||
let scope = str_p(p, "scope").unwrap_or("selected");
|
||||
let comp = s.active_comp().ok_or(EngineError::NoComp)?;
|
||||
let labels: Vec<u8> =
|
||||
s.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer)?.props.find(k.prop)?.keys.iter().find(|x| x.time == k.time).map(|x| x.label)).collect();
|
||||
if labels.is_empty() {
|
||||
return Err(bad(c, "select a keyframe first"));
|
||||
}
|
||||
let only_selected = match scope {
|
||||
"selected" | "visibleSelected" => true,
|
||||
"all" | "visibleAll" => false,
|
||||
_ => return Err(bad(c, "scope: selected|all|visibleSelected|visibleAll")),
|
||||
};
|
||||
let visible: Option<Vec<u64>> =
|
||||
(scope.starts_with("visible")).then(|| p.get("visible").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect())).flatten();
|
||||
let key_layers: Vec<_> = s.state.selected_keys.iter().map(|k| k.layer).collect();
|
||||
let mut sel = Vec::new();
|
||||
for l in &comp.layers {
|
||||
if only_selected && !s.state.selected_layers.contains(&l.id) && !key_layers.contains(&l.id) {
|
||||
continue;
|
||||
}
|
||||
l.props.walk("", &mut |_, pr| {
|
||||
if visible.as_ref().is_some_and(|v| !v.contains(&pr.uid)) {
|
||||
return;
|
||||
}
|
||||
for k in &pr.keys {
|
||||
if labels.contains(&k.label) {
|
||||
sel.push(KeyRef { layer: l.id, prop: pr.uid, time: k.time });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
s.state.selected_keys = sel;
|
||||
Ok(json!({"keys": s.state.selected_keys.len()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!("keys.setLabel", "Keyframe Label", [], None, "{label: name|none|0–16}", has_keys, set_label),
|
||||
cmd!(
|
||||
"keys.selectLabelGroup",
|
||||
"Select Keyframe Label Group",
|
||||
[],
|
||||
None,
|
||||
"{scope: selected|all|visibleSelected|visibleAll, visible?: [prop uid]}",
|
||||
has_comp,
|
||||
select_label_group
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ mod file_more;
|
||||
mod focus;
|
||||
mod frontend;
|
||||
mod help;
|
||||
mod key_labels;
|
||||
mod key_transform;
|
||||
mod layer;
|
||||
mod layer_menu;
|
||||
@@ -131,6 +132,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(viewer_cmds::specs());
|
||||
v.extend(shape_stroke::specs());
|
||||
v.extend(focus::specs());
|
||||
v.extend(key_labels::specs());
|
||||
v.extend(key_transform::specs());
|
||||
v.extend(file_more::specs());
|
||||
v.extend(lottie::specs());
|
||||
|
||||
@@ -23,7 +23,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
stub!("file.setProxy", "File...", ["File", "Set Proxy"], "{path}"),
|
||||
stub!("file.setProxyNone", "None", ["File", "Set Proxy"], "{}"),
|
||||
stub!("file.interpretProxy", "Proxy...", ["File", "Interpret Footage"], "{}"),
|
||||
stub!("keys.selectLabelGroup", "Select Keyframe Label Group", [], "{scope}"),
|
||||
// Layer ▸ New (3D / content-aware fill).
|
||||
stub!("layer.newContentAwareFill", "Content-Aware Fill Layer...", ["Layer", "New"], "{}"),
|
||||
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
|
||||
|
||||
@@ -220,6 +220,46 @@ fn adaptive_sample_limit_drives_layer_samples() {
|
||||
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyframe_label_groups() {
|
||||
let mut s = comp(200, 100);
|
||||
let a = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
let b = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 20, "height": 20})).unwrap()["layer"].as_u64().unwrap();
|
||||
for l in [a, b] {
|
||||
for t in [0.0, 0.5, 1.0] {
|
||||
s.execute("prop.addKey", json!({"layer": l, "path": "transform/opacity", "time": t})).unwrap();
|
||||
}
|
||||
}
|
||||
let uid = |s: &Session, l: u64| s.active_comp().unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().uid;
|
||||
let (ua, ub) = (uid(&s, a), uid(&s, b));
|
||||
// Label a's first key and b's last key Blue.
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}, {"layer": b, "prop": ub, "time": 1.0}]})).unwrap();
|
||||
s.execute("keys.setLabel", json!({"label": "Blue"})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.5}]})).unwrap();
|
||||
s.execute("keys.setLabel", json!({"label": "Green"})).unwrap();
|
||||
// On selected layers: from a's first key, only a's Blue key.
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "selected"})).unwrap()["keys"], json!(1));
|
||||
// On all layers: both Blue keys.
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(2));
|
||||
assert!(s.state.selected_keys.iter().any(|k| k.layer == LayerId(b) && (k.time.seconds() - 1.0).abs() < 1e-6));
|
||||
// Visible keyframes: only the revealed properties count.
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 0.0}]})).unwrap();
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "visibleAll", "visible": [ua]})).unwrap()["keys"], json!(1));
|
||||
// Unlabelled keys group together too (label None).
|
||||
s.execute("keys.select", json!({"keys": [{"layer": a, "prop": ua, "time": 1.0}]})).unwrap();
|
||||
assert_eq!(s.execute("keys.selectLabelGroup", json!({"scope": "all"})).unwrap()["keys"], json!(3));
|
||||
// Undo, serde.
|
||||
let lab = |s: &Session| s.active_comp().unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label;
|
||||
assert_eq!(lab(&s), 9);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(lab(&s), 0);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(roundtrip(&s).comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(a)).unwrap().props.prop("transform/opacity").unwrap().keys[1].label, 9);
|
||||
assert!(s.execute("keys.setLabel", json!({"label": "Mauve"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camera_focus_commands_undo_and_serde() {
|
||||
let mut s = comp(400, 300);
|
||||
|
||||
@@ -84,6 +84,13 @@ pub struct Keyframe {
|
||||
pub spatial_continuous: bool,
|
||||
#[serde(default)]
|
||||
pub roving: bool,
|
||||
/// Keyframe color label: index into the label list (0 = None).
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub label: u8,
|
||||
}
|
||||
|
||||
fn is_zero(v: &u8) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
@@ -106,6 +113,7 @@ impl Keyframe {
|
||||
spatial_auto: true,
|
||||
spatial_continuous: false,
|
||||
roving: false,
|
||||
label: 0,
|
||||
}
|
||||
}
|
||||
pub fn hold(mut self) -> Keyframe {
|
||||
|
||||
@@ -210,16 +210,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let name_w = (rect.width() * 0.40).clamp(120.0, 260.0);
|
||||
let label_x = rect.min.x + name_w + 14.0;
|
||||
let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)];
|
||||
let mut x = label_x + 16.0;
|
||||
// Optional columns wider than the panel scroll horizontally under the frozen Name and Label
|
||||
// columns (Shift+wheel / trackpad over the list, or the scroll bar under it).
|
||||
let opt_x0 = label_x + 16.0;
|
||||
let natural_end = opt_x0 + app.ui.project_columns.iter().filter_map(|k| COLUMNS.iter().find(|c| c.0 == k)).map(|c| c.2 + 4.0).sum::<f32>();
|
||||
let overflow = (natural_end - rect.max.x + 4.0).max(0.0);
|
||||
app.ui.project_hscroll = app.ui.project_hscroll.clamp(0.0, overflow);
|
||||
let hscroll = app.ui.project_hscroll;
|
||||
let mut x = opt_x0 - hscroll;
|
||||
for key in app.ui.project_columns.clone() {
|
||||
if let Some((k, l, w)) = COLUMNS.iter().find(|c| c.0 == key) {
|
||||
cols.push((k, l, x, *w));
|
||||
x += w + 4.0;
|
||||
}
|
||||
}
|
||||
let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max);
|
||||
let hp = p.with_clip_rect(hdr);
|
||||
for (key, label, x, w) in &cols {
|
||||
let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height()));
|
||||
let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) };
|
||||
if hr.width() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
|
||||
hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim });
|
||||
if *key == "label" {
|
||||
@@ -252,6 +264,29 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let footer_h = 28.0;
|
||||
let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
|
||||
let lp = p.with_clip_rect(list);
|
||||
if overflow > 0.0 && ui.rect_contains_pointer(list) {
|
||||
let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
|
||||
let d = if dx.abs() > 0.0 {
|
||||
dx
|
||||
} else if shift {
|
||||
dy
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
|
||||
}
|
||||
if overflow > 0.0 {
|
||||
let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
|
||||
let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0);
|
||||
let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height()));
|
||||
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag());
|
||||
p.rect_filled(track, 2.0, t.field_bg);
|
||||
p.rect_filled(thumb, 2.0, if sresp.hovered() || sresp.dragged() { t.text_dim } else { t.text_faint });
|
||||
app.auto.add("project.hscroll", track, &format!("{hscroll}/{overflow}"));
|
||||
if sresp.dragged() {
|
||||
app.ui.project_hscroll = (hscroll + sresp.drag_delta().x * overflow / (track.width() - tw).max(1.0)).clamp(0.0, overflow);
|
||||
}
|
||||
}
|
||||
let mut y = list.min.y;
|
||||
let query = app.ui.project_search.to_lowercase();
|
||||
let mut rows: Vec<(ItemId, usize)> = vec![];
|
||||
@@ -372,7 +407,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
// Optional columns.
|
||||
for (key, _, cx, w) in cols.iter().skip(2) {
|
||||
let cell = Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list);
|
||||
let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
|
||||
if cell.width() <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let cp = lp.with_clip_rect(cell);
|
||||
let txt = match *key {
|
||||
"type" => it.type_name().to_string(),
|
||||
|
||||
@@ -714,7 +714,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let footer_h = 24.0;
|
||||
let vis = Vis::of(&app.ui.timeline);
|
||||
let left_w = (vis.fixed() + 190.0).clamp(420.0, (rect.width() * 0.72).max(420.0));
|
||||
let cw = cols(rect.min.x, left_w, vis);
|
||||
// Columns wider than the pane scroll horizontally (Shift+wheel / trackpad over the outline,
|
||||
// or the scroll bar above the footer); the name column keeps its minimum width.
|
||||
let natural_w = vis.fixed() + 190.0;
|
||||
let overflow = (natural_w - left_w).max(0.0);
|
||||
app.ui.timeline.outline_scroll = app.ui.timeline.outline_scroll.clamp(0.0, overflow);
|
||||
let oscroll = app.ui.timeline.outline_scroll;
|
||||
let cw = cols(rect.min.x - oscroll, left_w + overflow, vis);
|
||||
let graph_x0 = rect.min.x + left_w + 1.0;
|
||||
let graph_x1 = rect.max.x - 10.0;
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
|
||||
@@ -898,8 +904,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg);
|
||||
p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator));
|
||||
let hy = ch.center().y;
|
||||
let hic = |icon: Icon, x: f32| icons::paint(&p, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
|
||||
let htext = |x: f32, s: &str| p.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
|
||||
// Column headers scroll with the outline and never spill into the time ruler.
|
||||
let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)));
|
||||
let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
|
||||
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
|
||||
if vis.av {
|
||||
hic(Icon::Eye, cw.av + 10.0);
|
||||
hic(Icon::Speaker, cw.av + 28.0);
|
||||
@@ -910,7 +918,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
htext(cw.keys + 6.0, "Keys");
|
||||
}
|
||||
if vis.label {
|
||||
icons::paint(&p, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
|
||||
icons::paint(&hp, Rect::from_center_size(pos2(cw.label + 10.0, hy), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
|
||||
}
|
||||
if vis.num {
|
||||
htext(cw.num + 6.0, "#");
|
||||
@@ -933,7 +941,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
htext(cw.trkmat + 4.0, "Track Matte");
|
||||
}
|
||||
if vis.parent {
|
||||
icons::paint(&p, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim);
|
||||
icons::paint(&hp, Rect::from_center_size(pos2(cw.parent + 10.0, hy), vec2(12.0, 12.0)), Icon::PickWhip, t.text_dim);
|
||||
htext(cw.parent + 20.0, "Parent & Link");
|
||||
}
|
||||
for (on, x, s) in [(vis.in_, cw.in_, "In"), (vis.out, cw.out, "Out"), (vis.duration, cw.duration, "Duration"), (vis.stretch, cw.stretch, "Stretch")] {
|
||||
@@ -1006,8 +1014,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if zoom && dy.abs() > 0.0 {
|
||||
zoom_at(app, &comp, tm, (dy as f64 / 200.0).exp(), ui.input(|i| i.pointer.hover_pos()).map(|p| p.x).unwrap_or(graph_x0));
|
||||
} else {
|
||||
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
|
||||
if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() {
|
||||
let over_outline = ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| p.x < graph_x0);
|
||||
let shift = ui.input(|i| i.modifiers.shift);
|
||||
if over_outline && overflow > 0.0 && (dx.abs() > 0.0 || (shift && dy.abs() > 0.0)) {
|
||||
let d = if dx.abs() > 0.0 { dx } else { dy };
|
||||
app.ui.timeline.outline_scroll = (app.ui.timeline.outline_scroll - d).clamp(0.0, overflow);
|
||||
} else {
|
||||
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
|
||||
}
|
||||
if dx.abs() > 0.0 && app.ui.timeline.pps.is_some() && !over_outline {
|
||||
app.ui.timeline.start = (app.ui.timeline.start - dx as f64 / pps).max(0.0);
|
||||
}
|
||||
}
|
||||
@@ -1623,7 +1638,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let ks = sel_keys.contains(&kref);
|
||||
let icon = k.icon();
|
||||
let c = pos2(x, cy);
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, if ks { t.keyframe_selected } else { t.keyframe }, Color32::from_black_alpha(200));
|
||||
// Labelled keys take their label colour (brighter when selected).
|
||||
let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
|
||||
Some(l) => {
|
||||
let c = t.label(*l);
|
||||
if ks { c } else { c.gamma_multiply(0.75) }
|
||||
}
|
||||
None if ks => t.keyframe_selected,
|
||||
None => t.keyframe,
|
||||
};
|
||||
icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol, Color32::from_black_alpha(200));
|
||||
let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
|
||||
let kresp = ui.interact(kr, egui::Id::new(("key", uid, k.time.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
|
||||
@@ -1660,6 +1684,23 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
("Select All Keyframes", "keys.selectAll", json!({})),
|
||||
("Delete", "keys.delete", json!({})),
|
||||
] {
|
||||
if lbl == "Select All Keyframes" {
|
||||
// Keyframe colour labels.
|
||||
ui.menu_button("Label", |ui| {
|
||||
for (i, l) in effectcraft_engine::color::Label::ALL.iter().enumerate() {
|
||||
if ui.add(egui::Button::new(l.name()).selected(k.label as usize == i)).clicked() {
|
||||
if !ks {
|
||||
actions.push((
|
||||
"keys.select".into(),
|
||||
json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}),
|
||||
));
|
||||
}
|
||||
actions.push(("keys.setLabel".into(), json!({"label": i})));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if lbl == "-" {
|
||||
ui.separator();
|
||||
continue;
|
||||
@@ -1794,6 +1835,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
|
||||
p.rect_filled(footer, 0.0, t.panel_bg);
|
||||
p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator));
|
||||
if overflow > 0.0 {
|
||||
// Outline scroll bar along the top of the footer, under the columns.
|
||||
let track = Rect::from_min_max(pos2(rect.min.x + 2.0, footer.min.y + 1.0), pos2(graph_x0 - 3.0, footer.min.y + 5.0));
|
||||
let frac = left_w / natural_w;
|
||||
let tw = (track.width() * frac).max(20.0);
|
||||
let tx = track.min.x + (track.width() - tw) * (oscroll / overflow);
|
||||
let thumb = Rect::from_min_size(pos2(tx, track.min.y), vec2(tw, track.height()));
|
||||
p.rect_filled(track, 2.0, t.field_bg);
|
||||
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("tl-outline-scroll"), Sense::drag());
|
||||
p.rect_filled(thumb, 2.0, if sresp.dragged() || sresp.hovered() { t.text_dim } else { t.text_faint });
|
||||
app.auto.add("timeline.outlineScroll", track, &format!("{oscroll}/{overflow}"));
|
||||
if sresp.dragged() {
|
||||
let k = overflow / (track.width() - tw).max(1.0);
|
||||
app.ui.timeline.outline_scroll = (oscroll + sresp.drag_delta().x * k).clamp(0.0, overflow);
|
||||
}
|
||||
}
|
||||
let tgl = Rect::from_min_size(pos2(footer.min.x + 10.0, footer.min.y + 3.0), vec2(150.0, 18.0));
|
||||
let tresp = ui.interact(tgl, egui::Id::new("tl-toggle-modes"), Sense::click());
|
||||
p.text(tgl.left_center(), Align2::LEFT_CENTER, "Toggle Switches / Modes", Tokens::ui(11.0), if tresp.hovered() { t.text } else { t.text_dim });
|
||||
|
||||
@@ -340,6 +340,9 @@ pub struct TimelineState {
|
||||
pub scroll_y: f32,
|
||||
/// Left column area width.
|
||||
pub columns_w: f32,
|
||||
/// Horizontal scroll of the outline columns (pixels) when they are wider than the pane.
|
||||
#[serde(default)]
|
||||
pub outline_scroll: f32,
|
||||
pub show_modes: bool,
|
||||
pub graph_editor: bool,
|
||||
pub search: String,
|
||||
@@ -403,6 +406,7 @@ impl Default for TimelineState {
|
||||
pps: None,
|
||||
scroll_y: 0.0,
|
||||
columns_w: 560.0,
|
||||
outline_scroll: 0.0,
|
||||
show_modes: false,
|
||||
graph_editor: false,
|
||||
search: String::new(),
|
||||
@@ -545,6 +549,9 @@ pub struct UiState {
|
||||
/// `duration`, `path`, `comment`); toggled from the column header's context menu.
|
||||
#[serde(default = "default_project_columns")]
|
||||
pub project_columns: Vec<String>,
|
||||
/// Horizontal scroll of the Project panel's optional columns (Name and Label stay put).
|
||||
#[serde(default)]
|
||||
pub project_hscroll: f32,
|
||||
/// Effect Controls twirl state (group uids that are collapsed).
|
||||
pub fx_closed: BTreeSet<u64>,
|
||||
/// Slider params whose slider row is twirled open (AE hides sliders by default).
|
||||
@@ -631,6 +638,7 @@ impl Default for UiState {
|
||||
project_open_folders: BTreeSet::new(),
|
||||
project_sort: default_project_sort(),
|
||||
project_sort_desc: false,
|
||||
project_hscroll: 0.0,
|
||||
project_columns: default_project_columns(),
|
||||
fx_closed: BTreeSet::new(),
|
||||
fx_slider_open: BTreeSet::new(),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Headless checks for the M13.5 long-tail UI: horizontal scrolling of the Timeline outline and
|
||||
//! Project panel columns (egui_kittest, UI logic only).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness(w: f32) -> Harness<'static, EffectcraftApp> {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Tail", "width": 640, "height": 360, "duration": 4})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080", "width": 640, "height": 360})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(w, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(from));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for i in 1..=8 {
|
||||
h.input_mut().events.push(Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_outline_columns_scroll_horizontally() {
|
||||
let mut h = harness(1100.0);
|
||||
// Every column on: wider than the outline pane.
|
||||
let ctx = h.ctx.clone();
|
||||
for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] {
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": true})).unwrap();
|
||||
}
|
||||
h.run_steps(3);
|
||||
let bar = rect(&h, "timeline.outlineScroll");
|
||||
let name0 = rect(&h, "timeline.header.name").min.x;
|
||||
assert_eq!(h.state().ui.timeline.outline_scroll, 0.0);
|
||||
drag(&mut h, pos2(bar.min.x + 8.0, bar.center().y), pos2(bar.max.x + 50.0, bar.center().y));
|
||||
h.run_steps(2);
|
||||
let s = h.state().ui.timeline.outline_scroll;
|
||||
assert!(s > 50.0, "{s}");
|
||||
let name1 = rect(&h, "timeline.header.name").min.x;
|
||||
assert!((name0 - name1 - s).abs() < 1.0, "the columns moved by the scroll: {name0} → {name1} ({s})");
|
||||
// The scroll is clamped to the overflow and resets when the columns fit again.
|
||||
for c in ["keys", "comment", "modes", "parent", "in", "out", "duration", "stretch"] {
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.column", json!({"column": c, "visible": false})).unwrap();
|
||||
}
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().ui.timeline.outline_scroll, 0.0);
|
||||
assert!(h.state().auto.find("timeline.outlineScroll").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_panel_columns_scroll_horizontally() {
|
||||
let mut h = harness(1400.0);
|
||||
for c in ["type", "size", "duration", "fps", "path", "comment"] {
|
||||
effectcraft_ui_egui::panels::project::set_column(&mut h.state_mut().ui.project_columns, c, true);
|
||||
}
|
||||
h.run_steps(3);
|
||||
let bar = rect(&h, "project.hscroll");
|
||||
let x0 = rect(&h, "project.sort.comment").min.x;
|
||||
let name0 = rect(&h, "project.sort.name").min.x;
|
||||
drag(&mut h, pos2(bar.min.x + 4.0, bar.center().y), pos2(bar.max.x + 40.0, bar.center().y));
|
||||
h.run_steps(2);
|
||||
let s = h.state().ui.project_hscroll;
|
||||
assert!(s > 20.0, "{s}");
|
||||
assert!(rect(&h, "project.sort.comment").min.x < x0 - 20.0);
|
||||
assert_eq!(rect(&h, "project.sort.name").min.x, name0, "Name stays frozen");
|
||||
}
|
||||
Reference in new issue
Block a user