mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M3.15: Timeline tabs per open comp; Project double-click opens; tab drag insertion mark
- The Timeline shows one tab per open comp (dock DocTab). Clicking a tab shows that comp; × or middle-click closes that comp's Timeline (comp.close), not the panel. Opening a comp brings its viewer and Timeline tab to the front. - Project panel: a double-click on an item's name opens it (a comp gets its own tab) instead of renaming; Enter and the context menu rename. - Tab drags: over a tab strip an insertion mark shows where the tab lands, and it lands there (Layout::insert_tab), which also reorders a group's own tabs; side zones are unchanged. - Tests: ui_tabs (double-click, tab switch / close, drag reorder and cross-group insert; an ignored mid-drag snapshot); the Timeline close test follows the new behaviour. - Docs: parity, control protocol. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a008a904af
commit
c7c90cfa74
8 files changed
+483
-81
No files matched your search
+184
-42
@@ -607,6 +607,39 @@ impl DockNode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Move panel `p` into the group that holds `anchor` as a tab before `before` (one of that
|
||||
/// group's panels) or last, and show it. False (tree unchanged) if `anchor` isn't docked.
|
||||
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
|
||||
if p == anchor || !self.contains(anchor) {
|
||||
return false;
|
||||
}
|
||||
let saved = self.clone();
|
||||
self.close(p);
|
||||
fn rec(n: &mut DockNode, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => rec(a, p, anchor, before) || rec(b, p, anchor, before),
|
||||
DockNode::Tabs { panels, active } if panels.contains(&anchor) => {
|
||||
let i = before.and_then(|b| panels.iter().position(|x| *x == b)).unwrap_or(panels.len());
|
||||
panels.insert(i, p);
|
||||
*active = i;
|
||||
true
|
||||
}
|
||||
DockNode::Stack { entries } if entries.iter().any(|e| e.panel == anchor) => {
|
||||
let i = before.and_then(|b| entries.iter().position(|e| e.panel == b)).unwrap_or(entries.len());
|
||||
entries.insert(i, StackEntry { panel: p, open: true, height: None });
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
if rec(self, p, anchor, before) {
|
||||
true
|
||||
} else {
|
||||
*self = saved;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the group containing `p` (as in [`Group::path`]; stacks add `sN`).
|
||||
pub fn path_of(&self, p: PanelKind) -> Option<String> {
|
||||
fn rec(n: &DockNode, p: PanelKind, path: &str) -> Option<String> {
|
||||
@@ -664,6 +697,36 @@ impl Layout {
|
||||
found
|
||||
}
|
||||
|
||||
/// Move `p` (docked or floating) into the group of `anchor` (docked or floating) as a tab
|
||||
/// before `before` or last: where a tab dragged onto a tab strip lands.
|
||||
pub fn insert_tab(&mut self, p: PanelKind, anchor: PanelKind, before: Option<PanelKind>) -> bool {
|
||||
if p == anchor || before == Some(p) {
|
||||
return false;
|
||||
}
|
||||
if self.floating.iter().any(|f| f.panels.contains(&anchor)) {
|
||||
if self.root.contains(p) {
|
||||
if self.root.panel_count() <= 1 {
|
||||
return false;
|
||||
}
|
||||
self.root.close(p);
|
||||
} else {
|
||||
self.unfloat(p);
|
||||
}
|
||||
// (Removing `p` may have dropped an emptied group: look the anchor up again.)
|
||||
let Some(f) = self.floating.iter_mut().find(|f| f.panels.contains(&anchor)) else { return false };
|
||||
let i = before.and_then(|b| f.panels.iter().position(|x| *x == b)).unwrap_or(f.panels.len());
|
||||
f.panels.insert(i, p);
|
||||
f.active = i;
|
||||
return true;
|
||||
}
|
||||
if !self.root.contains(anchor) {
|
||||
return false;
|
||||
}
|
||||
// A floating panel isn't in the tree, so insert_tab's removal is a no-op for it.
|
||||
self.unfloat(p);
|
||||
self.root.insert_tab(p, anchor, before)
|
||||
}
|
||||
|
||||
/// Dock `p` (docked or floating) next to `anchor`; `anchor` may be floating too (then Center
|
||||
/// adds a tab to that floating group).
|
||||
pub fn dock(&mut self, p: PanelKind, anchor: PanelKind, zone: Zone) -> bool {
|
||||
@@ -724,6 +787,37 @@ pub enum DockAction {
|
||||
BeginDrag(PanelKind),
|
||||
/// The tab's lock was toggled (Composition / Timeline viewer lock).
|
||||
ToggleLock(PanelKind),
|
||||
/// A document tab of a panel was clicked (the Timeline's tab of comp `id`).
|
||||
ActivateDoc(PanelKind, u64),
|
||||
/// A document tab's close button (or middle click): close that document (comp `id`).
|
||||
CloseDoc(PanelKind, u64),
|
||||
}
|
||||
|
||||
/// One document a panel shows as a tab of its own (the Timeline: one tab per open comp, as in
|
||||
/// After Effects).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DocTab {
|
||||
pub id: u64,
|
||||
pub title: String,
|
||||
pub deco: Option<TabDeco>,
|
||||
/// The document the panel shows now.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// What a group's tabs show: labels, the comp swatch / close / lock decorations and the panels
|
||||
/// that show one tab per document.
|
||||
pub struct TabInfo<'a> {
|
||||
pub title: &'a dyn Fn(PanelKind) -> String,
|
||||
pub decos: &'a [(PanelKind, TabDeco)],
|
||||
pub docs: &'a [(PanelKind, Vec<DocTab>)],
|
||||
}
|
||||
|
||||
/// What drawing a group's chrome produced: actions and where each panel's tabs are (a panel with
|
||||
/// document tabs spans all of them; tabs hidden behind the overflow chevron are left out).
|
||||
#[derive(Default)]
|
||||
pub struct Chrome {
|
||||
pub actions: Vec<DockAction>,
|
||||
pub tabs: Vec<(PanelKind, Rect)>,
|
||||
}
|
||||
|
||||
/// After Effects-style extras on a tab that shows a composition (Composition, Timeline): a close
|
||||
@@ -810,18 +904,21 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw a group's frame + tab strip. Returns actions (tab clicks, panel menu, focus).
|
||||
/// `title` gives a tab's label, which may name the comp or layer it shows (After Effects style).
|
||||
pub fn draw_group_chrome(
|
||||
ui: &mut egui::Ui,
|
||||
g: &Group,
|
||||
focused: PanelKind,
|
||||
t: &Tokens,
|
||||
reg: &mut crate::automation::Registry,
|
||||
title: &dyn Fn(PanelKind) -> String,
|
||||
decos: &[(PanelKind, TabDeco)],
|
||||
) -> Vec<DockAction> {
|
||||
let mut actions = Vec::new();
|
||||
/// Draw a group's frame + tab strip: one tab per panel, or per document for panels that show
|
||||
/// several (the Timeline's comps). Tab labels may name the comp or layer they show (After
|
||||
/// Effects style).
|
||||
pub fn draw_group_chrome(ui: &mut egui::Ui, g: &Group, focused: PanelKind, t: &Tokens, reg: &mut crate::automation::Registry, info: &TabInfo) -> Chrome {
|
||||
struct Entry {
|
||||
panel: PanelKind,
|
||||
doc: Option<u64>,
|
||||
label: String,
|
||||
deco: Option<TabDeco>,
|
||||
/// The group's shown tab.
|
||||
active: bool,
|
||||
/// The document its panel shows (even behind another tab).
|
||||
shown_doc: bool,
|
||||
}
|
||||
let mut out = Chrome::default();
|
||||
let painter = ui.painter().clone();
|
||||
painter.rect_filled(g.rect, t.radius, t.panel_bg);
|
||||
let active_panel = g.panels.get(g.active).copied();
|
||||
@@ -833,57 +930,102 @@ pub fn draw_group_chrome(
|
||||
painter.circle_filled(c + vec2(dx, 0.0), 1.0, t.text_faint);
|
||||
}
|
||||
} else {
|
||||
let mut entries: Vec<Entry> = vec![];
|
||||
for (pi, p) in g.panels.iter().enumerate() {
|
||||
let shown = pi == g.active;
|
||||
match info.docs.iter().find(|(k, d)| k == p && !d.is_empty()) {
|
||||
Some((_, docs)) => entries.extend(docs.iter().map(|d| Entry {
|
||||
panel: *p,
|
||||
doc: Some(d.id),
|
||||
label: d.title.clone(),
|
||||
deco: d.deco,
|
||||
active: shown && d.active,
|
||||
shown_doc: d.active,
|
||||
})),
|
||||
None => entries.push(Entry {
|
||||
panel: *p,
|
||||
doc: None,
|
||||
label: (info.title)(*p),
|
||||
deco: info.decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d),
|
||||
active: shown,
|
||||
shown_doc: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
let ai = entries.iter().position(|e| e.active).unwrap_or(0);
|
||||
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
|
||||
if t.gradients {
|
||||
// Settings ▸ Appearance ▸ Use Gradients.
|
||||
crate::theme::gradient_rect(&painter, strip.shrink2(vec2(t.radius, 0.0)), t.grad_top(t.panel_bg), t.panel_bg);
|
||||
}
|
||||
let activate = |e: &Entry| match e.doc {
|
||||
Some(id) => DockAction::ActivateDoc(e.panel, id),
|
||||
None => DockAction::Activate(e.panel),
|
||||
};
|
||||
let close = |e: &Entry| match e.doc {
|
||||
Some(id) => DockAction::CloseDoc(e.panel, id),
|
||||
None => DockAction::Close(e.panel),
|
||||
};
|
||||
let mut x = strip.min.x + 12.0;
|
||||
let text_y = strip.min.y + 16.0;
|
||||
for (i, p) in g.panels.iter().enumerate() {
|
||||
for (k, e) in entries.iter().enumerate() {
|
||||
let p = &e.panel;
|
||||
// A collapsed stacked panel's header is plain text (no underline or panel menu).
|
||||
let is_active = i == g.active && g.stacked != Some(false);
|
||||
let label = title(*p);
|
||||
let galley = painter.layout_no_wrap(label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let is_active = k == ai && g.stacked != Some(false);
|
||||
let galley = painter.layout_no_wrap(e.label.clone(), Tokens::ui(12.0), if is_active { t.tab_text_active } else { t.tab_text });
|
||||
let menu_w = if is_active { 20.0 } else { 0.0 };
|
||||
let deco = decos.iter().find(|(k, _)| k == p).map(|(_, d)| *d);
|
||||
// The lock belongs to the panel: on its shown document's tab only.
|
||||
let lock = e.deco.is_some_and(|d| d.viewer) && (e.doc.is_none() || is_active);
|
||||
// × (active tab only), swatch and lock before the label.
|
||||
let deco_w = match deco {
|
||||
Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + 16.0,
|
||||
let deco_w = match e.deco {
|
||||
Some(d) if d.viewer => (if is_active { 16.0 } else { 0.0 }) + 14.0 + if lock { 16.0 } else { 0.0 },
|
||||
Some(_) => 14.0,
|
||||
None => 0.0,
|
||||
};
|
||||
let w = galley.size().x + 16.0 + menu_w + deco_w;
|
||||
if x + w > strip.max.x - 20.0 && i > g.active {
|
||||
if x + w > strip.max.x - 20.0 && k > ai {
|
||||
let r = Rect::from_min_size(pos2(strip.max.x - 22.0, strip.min.y + 6.0), vec2(18.0, 20.0));
|
||||
let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click());
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, t.tab_text);
|
||||
if resp.clicked() {
|
||||
let next = g.panels[(g.active + 1) % g.panels.len()];
|
||||
actions.push(DockAction::Activate(next));
|
||||
if resp.clicked()
|
||||
&& let Some(next) = entries.get((ai + 1) % entries.len())
|
||||
{
|
||||
out.actions.push(activate(next));
|
||||
}
|
||||
break;
|
||||
}
|
||||
let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, t.tab_h));
|
||||
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag());
|
||||
match out.tabs.iter_mut().find(|(q, _)| q == p) {
|
||||
Some((_, r)) => *r = r.union(tab),
|
||||
None => out.tabs.push((*p, tab)),
|
||||
}
|
||||
let resp = ui.interact(tab, egui::Id::new(("tab", g.path.clone(), k)), Sense::click_and_drag());
|
||||
if resp.drag_started() {
|
||||
actions.push(DockAction::BeginDrag(*p));
|
||||
out.actions.push(DockAction::BeginDrag(*p));
|
||||
}
|
||||
// A document tab also answers to the panel's id while it is the shown one.
|
||||
let auto_id = match e.doc {
|
||||
Some(id) => format!("panel.tab.{}.{id}", p.id()),
|
||||
None => format!("panel.tab.{}", p.id()),
|
||||
};
|
||||
reg.add(&auto_id, tab, &e.label);
|
||||
if e.shown_doc {
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, &e.label);
|
||||
}
|
||||
reg.add(&format!("panel.tab.{}", p.id()), tab, &label);
|
||||
let mut label_x = tab.min.x + 8.0;
|
||||
if let Some(d) = deco {
|
||||
if let Some(d) = e.deco {
|
||||
let mut dx = tab.min.x + 6.0;
|
||||
if is_active && d.viewer {
|
||||
let cr = Rect::from_center_size(pos2(dx + 5.0, text_y), vec2(10.0, 10.0));
|
||||
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), i)), Sense::click());
|
||||
let cresp = ui.interact(cr.expand(2.0), egui::Id::new(("tab-close", g.path.clone(), k)), Sense::click());
|
||||
let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text };
|
||||
let k = 3.5;
|
||||
painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc));
|
||||
painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc));
|
||||
let kk = 3.5;
|
||||
painter.line_segment([cr.center() + vec2(-kk, -kk), cr.center() + vec2(kk, kk)], Stroke::new(1.2, cc));
|
||||
painter.line_segment([cr.center() + vec2(-kk, kk), cr.center() + vec2(kk, -kk)], Stroke::new(1.2, cc));
|
||||
reg.add(&format!("panel.tab.{}.close", p.id()), cr, "Close");
|
||||
if cresp.clicked() {
|
||||
actions.push(DockAction::Close(*p));
|
||||
out.actions.push(close(e));
|
||||
}
|
||||
dx += 16.0;
|
||||
}
|
||||
@@ -891,9 +1033,9 @@ pub fn draw_group_chrome(
|
||||
painter.rect_filled(sw, 1.0, d.swatch);
|
||||
dx += 14.0;
|
||||
label_x = dx;
|
||||
if d.viewer {
|
||||
if lock {
|
||||
let lr = Rect::from_center_size(pos2(dx + 6.0, text_y), vec2(12.0, 12.0));
|
||||
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), i)), Sense::click());
|
||||
let lresp = ui.interact(lr.expand(2.0), egui::Id::new(("tab-lock", g.path.clone(), k)), Sense::click());
|
||||
let lc = if d.locked {
|
||||
t.tab_text_active
|
||||
} else if lresp.hovered() {
|
||||
@@ -904,7 +1046,7 @@ pub fn draw_group_chrome(
|
||||
icons::paint(&painter, lr, Icon::Lock, lc);
|
||||
reg.add(&format!("panel.tab.{}.lock", p.id()), lr, if d.locked { "Unlock" } else { "Lock" });
|
||||
if lresp.clicked() {
|
||||
actions.push(DockAction::ToggleLock(*p));
|
||||
out.actions.push(DockAction::ToggleLock(*p));
|
||||
}
|
||||
label_x = dx + 16.0;
|
||||
}
|
||||
@@ -923,19 +1065,19 @@ pub fn draw_group_chrome(
|
||||
let uy = strip.min.y + 23.0;
|
||||
painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active));
|
||||
if mresp.clicked() {
|
||||
actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
|
||||
out.actions.push(DockAction::PanelMenu(*p, mr.left_bottom()));
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
if g.stacked.is_some() {
|
||||
actions.push(DockAction::ToggleStacked(*p));
|
||||
out.actions.push(DockAction::ToggleStacked(*p));
|
||||
} else {
|
||||
actions.push(DockAction::Activate(*p));
|
||||
out.actions.push(activate(e));
|
||||
}
|
||||
actions.push(DockAction::Focus(*p));
|
||||
out.actions.push(DockAction::Focus(*p));
|
||||
}
|
||||
if resp.middle_clicked() {
|
||||
actions.push(DockAction::Close(*p));
|
||||
out.actions.push(close(e));
|
||||
}
|
||||
x += w + 8.0;
|
||||
}
|
||||
@@ -949,9 +1091,9 @@ pub fn draw_group_chrome(
|
||||
&& ui.rect_contains_pointer(g.rect)
|
||||
&& ui.input(|i| i.pointer.any_pressed())
|
||||
{
|
||||
actions.push(DockAction::Focus(p));
|
||||
out.actions.push(DockAction::Focus(p));
|
||||
}
|
||||
actions
|
||||
out
|
||||
}
|
||||
|
||||
/// Placeholder body for panels that are not implemented yet.
|
||||
|
||||
+131
-30
@@ -15,7 +15,8 @@ fn drag_id() -> egui::Id {
|
||||
|
||||
impl EffectcraftApp {
|
||||
/// Tab labels that name what the panel shows, as After Effects does: "Composition Intro",
|
||||
/// "Effect Controls Title", "Properties: Title", and the Timeline tab named after its comp.
|
||||
/// "Effect Controls Title", "Properties: Title" (the Timeline's tabs are its comps, see
|
||||
/// [`Self::tab_docs`]).
|
||||
fn tab_titles(&self) -> Vec<(PanelKind, String)> {
|
||||
let mut out = Vec::new();
|
||||
// ScriptUI panels are named after their script.
|
||||
@@ -27,7 +28,6 @@ impl EffectcraftApp {
|
||||
let Some(comp) = self.session.active_comp() else { return out };
|
||||
let cname = self.session.active_comp_id().and_then(|id| self.session.project.item(id)).map(|i| i.name.clone()).unwrap_or_default();
|
||||
out.push((PanelKind::Composition, format!("Composition {cname}")));
|
||||
out.push((PanelKind::Timeline, cname));
|
||||
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
|
||||
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
|
||||
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
|
||||
@@ -35,13 +35,35 @@ impl EffectcraftApp {
|
||||
out
|
||||
}
|
||||
|
||||
/// A comp's label colour as a tab swatch (faint without a label).
|
||||
fn comp_swatch(&self, cid: effectcraft_engine::project::ItemId) -> Color32 {
|
||||
match self.session.project.item(cid).map(|i| i.label) {
|
||||
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
|
||||
_ => self.tokens.text_faint,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Timeline's tabs: one per open comp, as in After Effects (opening a precomp adds a tab
|
||||
/// next to its parent's instead of replacing it).
|
||||
fn tab_docs(&self) -> Vec<(PanelKind, Vec<dock::DocTab>)> {
|
||||
let st = &self.session.state;
|
||||
let locked = self.ui.locked_tabs.contains(&PanelKind::Timeline.id());
|
||||
let docs = st
|
||||
.open_comps
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let it = self.session.project.item(*id).filter(|i| i.as_comp().is_some())?;
|
||||
let deco = dock::TabDeco { swatch: self.comp_swatch(*id), locked, viewer: true };
|
||||
Some(dock::DocTab { id: id.0, title: it.name.clone(), deco: Some(deco), active: st.active_comp == Some(*id) })
|
||||
})
|
||||
.collect();
|
||||
vec![(PanelKind::Timeline, docs)]
|
||||
}
|
||||
|
||||
/// The Composition and Timeline tabs' close button, label-colour swatch and viewer lock.
|
||||
fn tab_decos(&self) -> Vec<(PanelKind, dock::TabDeco)> {
|
||||
let Some(cid) = self.session.active_comp_id() else { return vec![] };
|
||||
let swatch = match self.session.project.item(cid).map(|i| i.label) {
|
||||
Some(l) if l != effectcraft_engine::color::Label::None => self.tokens.label(l),
|
||||
_ => self.tokens.text_faint,
|
||||
};
|
||||
let swatch = self.comp_swatch(cid);
|
||||
let mut v: Vec<(PanelKind, dock::TabDeco)> = [PanelKind::Composition, PanelKind::Timeline]
|
||||
.into_iter()
|
||||
.map(|p| (p, dock::TabDeco { swatch, locked: self.ui.locked_tabs.contains(&p.id()), viewer: true }))
|
||||
@@ -149,9 +171,15 @@ impl EffectcraftApp {
|
||||
let mut actions = Vec::new();
|
||||
let titles = self.tab_titles();
|
||||
let decos = self.tab_decos();
|
||||
let docs = self.tab_docs();
|
||||
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
|
||||
let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs };
|
||||
// Where each group's tabs are (tab drags show where a tab will land between them).
|
||||
let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![];
|
||||
for g in &groups {
|
||||
actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &title, &decos));
|
||||
let c = dock::draw_group_chrome(ui, g, self.ui.focused, &t, &mut self.auto, &info);
|
||||
actions.extend(c.actions);
|
||||
tab_rects.push((g.path.clone(), c.tabs));
|
||||
}
|
||||
self.label_tab_marks(ui);
|
||||
if maximized.is_none() {
|
||||
@@ -168,7 +196,8 @@ impl EffectcraftApp {
|
||||
// Floating groups over the dock.
|
||||
let mut float_groups: Vec<Group> = vec![];
|
||||
for i in 0..self.ui.floating.len() {
|
||||
if let Some(g) = self.floating_window(&ctx, i, &title, &mut actions) {
|
||||
if let Some((g, tabs)) = self.floating_window(&ctx, i, &info, &mut actions) {
|
||||
tab_rects.push((g.path.clone(), tabs));
|
||||
float_groups.push(g);
|
||||
}
|
||||
}
|
||||
@@ -197,15 +226,35 @@ impl EffectcraftApp {
|
||||
self.ui.locked_tabs.insert(p.id().to_string());
|
||||
}
|
||||
}
|
||||
// The Timeline's comp tabs: show that comp / close its Timeline.
|
||||
DockAction::ActivateDoc(p, id) => {
|
||||
if !self.ui.dock.activate(p)
|
||||
&& let Some(f) = self.ui.floating.iter_mut().find(|f| f.panels.contains(&p))
|
||||
{
|
||||
f.active = f.panels.iter().position(|x| *x == p).unwrap_or(0);
|
||||
}
|
||||
if self.session.state.active_comp.map(|c| c.0) != Some(id) {
|
||||
let _ = crate::menus::invoke(self, &ctx, "comp.open", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
DockAction::CloseDoc(_, id) => {
|
||||
let _ = crate::menus::invoke(self, &ctx, "comp.close", serde_json::json!({"comp": id}));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tab_drag(&ctx, &groups, &float_groups);
|
||||
self.tab_drag(&ctx, &groups, &float_groups, &tab_rects);
|
||||
panels::panel_menu_popup(self, ui);
|
||||
}
|
||||
|
||||
/// One floating group: a window with a tab strip (drag the empty strip to move it, the
|
||||
/// corner to resize it) and the active panel's body.
|
||||
fn floating_window(&mut self, ctx: &egui::Context, i: usize, title: &dyn Fn(PanelKind) -> String, actions: &mut Vec<DockAction>) -> Option<Group> {
|
||||
fn floating_window(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
i: usize,
|
||||
info: &dock::TabInfo,
|
||||
actions: &mut Vec<DockAction>,
|
||||
) -> Option<(Group, Vec<(PanelKind, Rect)>)> {
|
||||
let t = self.tokens;
|
||||
let f = self.ui.floating.get(i)?.clone();
|
||||
let screen = ctx.content_rect();
|
||||
@@ -236,8 +285,8 @@ impl EffectcraftApp {
|
||||
active: f.active.min(f.panels.len().saturating_sub(1)),
|
||||
stacked: None,
|
||||
};
|
||||
let decos = self.tab_decos();
|
||||
actions.extend(dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, title, &decos));
|
||||
let c = dock::draw_group_chrome(ui, &g, self.ui.focused, &t, &mut self.auto, info);
|
||||
actions.extend(c.actions);
|
||||
ui.painter().rect_stroke(r, t.radius, Stroke::new(1.0, t.field_border), StrokeKind::Outside);
|
||||
if let Some(p) = g.panels.get(g.active).copied() {
|
||||
self.panel_body(ui, p, g.content);
|
||||
@@ -261,14 +310,16 @@ impl EffectcraftApp {
|
||||
{
|
||||
fl.rect = [nr.min.x, nr.min.y, nr.width(), nr.height()];
|
||||
}
|
||||
group = Some(g);
|
||||
group = Some((g, c.tabs));
|
||||
});
|
||||
group
|
||||
}
|
||||
|
||||
/// A tab being dragged: highlight the drop zone under the pointer; on release, dock (or
|
||||
/// float with Cmd/Ctrl or outside every group).
|
||||
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group]) {
|
||||
/// A tab being dragged. Over a tab strip (or the middle of a group) it shows where the tab
|
||||
/// will land: an insertion mark between two tabs (a drop there reorders a group's own tabs
|
||||
/// too); near a group's edge, the half it will split off. On release it lands there (or
|
||||
/// floats with Cmd/Ctrl or outside every group).
|
||||
fn tab_drag(&mut self, ctx: &egui::Context, groups: &[Group], floats: &[Group], tab_rects: &[(String, Vec<(PanelKind, Rect)>)]) {
|
||||
let Some(p) = ctx.data(|d| d.get_temp::<PanelKind>(drag_id())) else { return };
|
||||
let t = self.tokens;
|
||||
let Some(pos) = ctx.pointer_latest_pos() else { return };
|
||||
@@ -276,23 +327,55 @@ impl EffectcraftApp {
|
||||
let target = floats.iter().map(|g| (g, true)).chain(groups.iter().map(|g| (g, false))).find(|(g, _)| g.rect.contains(pos));
|
||||
let float_drop = ctx.input(|i| i.modifiers.command);
|
||||
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop")));
|
||||
let plan: Option<(PanelKind, Zone)> = match target {
|
||||
let plan: Option<DropPlan> = match target {
|
||||
Some((g, is_float)) if !float_drop => {
|
||||
let anchor = g.panels.get(g.active).copied().unwrap_or(p);
|
||||
let others: Vec<PanelKind> = g.panels.iter().copied().filter(|x| *x != p).collect();
|
||||
let zone = if is_float { Zone::Center } else { dock::drop_zone(g.rect, t.tab_h, pos) };
|
||||
let self_only = anchor == p && g.panels.len() == 1;
|
||||
if self_only {
|
||||
None
|
||||
} else {
|
||||
let anchor = if anchor == p { g.panels.iter().copied().find(|x| *x != p).unwrap_or(p) } else { anchor };
|
||||
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
|
||||
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
|
||||
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
|
||||
// Outline the whole group for the side zones, like AE's drop-zone frame.
|
||||
if zone != Zone::Center {
|
||||
match (others.first().copied(), zone) {
|
||||
// The dragged tab alone in its group: nowhere to go here.
|
||||
(None, _) => None,
|
||||
(Some(first), Zone::Center) => {
|
||||
let rects: Vec<(PanelKind, Rect)> = tab_rects
|
||||
.iter()
|
||||
.find(|(path, _)| *path == g.path)
|
||||
.map(|(_, r)| r.iter().copied().filter(|(k, _)| *k != p).collect())
|
||||
.unwrap_or_default();
|
||||
let strip = Rect::from_min_size(g.rect.min, vec2(g.rect.width(), t.tab_h));
|
||||
let before = if strip.contains(pos) {
|
||||
// Before the first tab whose middle is right of the pointer.
|
||||
rects.iter().find(|(_, r)| r.center().x > pos.x).map(|(k, _)| *k)
|
||||
} else if g.panels.contains(&p) {
|
||||
// The middle of its own group: it stays where it is.
|
||||
Some(p)
|
||||
} else {
|
||||
// The middle of another group: right after its shown tab.
|
||||
let shown = g.panels.get(g.active).and_then(|a| others.iter().position(|x| x == a)).unwrap_or(others.len());
|
||||
others.get(shown + 1).copied()
|
||||
};
|
||||
if before == Some(p) {
|
||||
None
|
||||
} else {
|
||||
let x = match before.and_then(|b| rects.iter().find(|(k, _)| *k == b)) {
|
||||
Some((_, r)) => r.min.x - 4.0,
|
||||
None => rects.last().map_or(strip.min.x + 8.0, |(_, r)| r.max.x + 4.0),
|
||||
};
|
||||
painter.rect_filled(strip.shrink2(vec2(2.0, 1.0)), t.radius, t.focus.gamma_multiply(0.12));
|
||||
if !strip.contains(pos) {
|
||||
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
}
|
||||
insertion_mark(&painter, x, strip, t.focus);
|
||||
Some(DropPlan::Tab { anchor: first, before })
|
||||
}
|
||||
}
|
||||
(Some(first), zone) => {
|
||||
let shown = g.panels.get(g.active).copied().filter(|a| *a != p).unwrap_or(first);
|
||||
let hr = dock::zone_rect(g.rect, zone).shrink(2.0);
|
||||
painter.rect_filled(hr, t.radius, t.focus.gamma_multiply(0.25));
|
||||
painter.rect_stroke(hr, t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside);
|
||||
// Outline the whole group for the side zones, like AE's drop-zone frame.
|
||||
painter.rect_stroke(g.rect, t.radius, Stroke::new(1.0, t.focus.gamma_multiply(0.6)), StrokeKind::Inside);
|
||||
Some(DropPlan::Side { anchor: shown, zone })
|
||||
}
|
||||
Some((anchor, zone))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
@@ -307,7 +390,8 @@ impl EffectcraftApp {
|
||||
if ctx.input(|i| i.pointer.any_released()) {
|
||||
ctx.data_mut(|d| d.remove::<PanelKind>(drag_id()));
|
||||
let ok = match plan {
|
||||
Some((anchor, zone)) => self.edit_layout(|l| l.dock(p, anchor, zone)),
|
||||
Some(DropPlan::Tab { anchor, before }) => self.edit_layout(|l| l.insert_tab(p, anchor, before)),
|
||||
Some(DropPlan::Side { anchor, zone }) => self.edit_layout(|l| l.dock(p, anchor, zone)),
|
||||
None if float_drop || target.is_none() => {
|
||||
let r = [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0];
|
||||
self.edit_layout(|l| l.float(p, r))
|
||||
@@ -322,6 +406,23 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a dragged tab lands.
|
||||
enum DropPlan {
|
||||
/// A tab in the group of `anchor`, before `before` (last when `None`).
|
||||
Tab { anchor: PanelKind, before: Option<PanelKind> },
|
||||
/// A new group split off one side of `anchor`'s group.
|
||||
Side { anchor: PanelKind, zone: Zone },
|
||||
}
|
||||
|
||||
/// The insertion mark of a tab drag: a bar between two tabs at `x`, with caps.
|
||||
fn insertion_mark(painter: &egui::Painter, x: f32, strip: Rect, col: Color32) {
|
||||
let (y0, y1) = (strip.min.y + 3.0, strip.max.y - 2.0);
|
||||
painter.line_segment([pos2(x, y0), pos2(x, y1)], Stroke::new(2.5, col));
|
||||
for (y, d) in [(y0, 1.0), (y1, -1.0)] {
|
||||
painter.add(egui::Shape::convex_polygon(vec![pos2(x - 4.5, y), pos2(x + 4.5, y), pos2(x, y + 5.0 * d)], col, Stroke::NONE));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new floating group's default rect near the centre of `screen`.
|
||||
pub fn default_float_rect(screen: Rect) -> [f32; 4] {
|
||||
let c = screen.center();
|
||||
|
||||
@@ -1121,8 +1121,11 @@ impl EffectcraftApp {
|
||||
for ev in self.session.drain_events() {
|
||||
match ev {
|
||||
effectcraft_engine::Event::OpenComp(_) => {
|
||||
if !self.ui.locked_tabs.contains(&PanelKind::Composition.id()) {
|
||||
self.ui.dock.activate(PanelKind::Composition);
|
||||
// The comp's viewer and its Timeline tab come to the front.
|
||||
for p in [PanelKind::Composition, PanelKind::Timeline] {
|
||||
if !self.ui.locked_tabs.contains(&p.id()) {
|
||||
self.ui.dock.activate(p);
|
||||
}
|
||||
}
|
||||
self.ui.timeline.pps = None;
|
||||
}
|
||||
|
||||
@@ -535,7 +535,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
|
||||
let nresp = ui.interact(name_clip, egui::Id::new(("pname", id.0)), Sense::click_and_drag());
|
||||
app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
|
||||
let resp = resp.union(nresp.clone());
|
||||
let resp = resp.union(nresp);
|
||||
if resp.clicked() {
|
||||
let add = ui.input(|i| i.modifiers.command || i.modifiers.shift);
|
||||
if add {
|
||||
@@ -546,10 +546,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.session.state.project_selection = vec![*id];
|
||||
}
|
||||
}
|
||||
if nresp.double_clicked() {
|
||||
// Double-click the name: rename in place.
|
||||
ctx.data_mut(|d| d.insert_temp::<Editing>(edit_id(), (id.0, "name".into(), it.name.clone())));
|
||||
} else if resp.double_clicked() {
|
||||
// Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and
|
||||
// the viewer); Enter or the context menu renames.
|
||||
if resp.double_clicked() {
|
||||
match &it.kind {
|
||||
ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
|
||||
ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
|
||||
|
||||
@@ -264,6 +264,9 @@ fn composition_tabs_and_scrolling_preview() {
|
||||
assert!(h.state().ui.locked_tabs.contains("Composition"));
|
||||
click_id(&mut h, "panel.tab.Composition.lock");
|
||||
assert!(h.state().ui.locked_tabs.is_empty());
|
||||
// The Timeline's × closes its comp's tab (the panel stays, as in After Effects).
|
||||
let shown = h.state().session.state.active_comp.unwrap();
|
||||
click_id(&mut h, "panel.tab.Timeline.close");
|
||||
assert!(!h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
assert!(!h.state().session.state.open_comps.contains(&shown));
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Comp tabs and tab drags (egui_kittest): double-clicking a comp in the Project panel opens it
|
||||
//! as its own Timeline tab (not a rename, and not in place of the comp shown), the tabs switch and
|
||||
//! close comps, and a dragged tab lands where its insertion mark shows.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemId;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use effectcraft_ui_egui::dock::{DockNode, PanelKind};
|
||||
use egui::{Event, Pos2, pos2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// "Pre" and "Main" (Main holds Pre); only Main is open.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64, u64) {
|
||||
let mut s = Session::default();
|
||||
let pre = s.execute("comp.new", json!({"name": "Pre", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("layer.addItem", json!({"item": pre})).unwrap();
|
||||
s.state.open_comps = vec![ItemId(main)];
|
||||
s.state.active_comp = Some(ItemId(main));
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
(h, pre, main)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> egui::Rect {
|
||||
let e = h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).find(|e| e.id == id).cloned().unwrap_or_else(|| {
|
||||
let have: Vec<&String> = h.state().auto.elements.iter().map(|e| &e.id).filter(|i| i.starts_with("panel.") || i.starts_with("project.")).collect();
|
||||
panic!("no element {id}; have {have:?}")
|
||||
});
|
||||
egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn click_n(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, n: u32) {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
// All in one frame, straight into the input (the harness' event queue spreads presses over
|
||||
// frames, longer apart than a double-click).
|
||||
for _ in 0..n {
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
let f = k as f32 / 6.0;
|
||||
h.event(Event::PointerMoved(from + (to - from) * f));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The tabs of the docked group holding `p`, in order.
|
||||
fn group_of(n: &DockNode, p: PanelKind) -> Option<Vec<PanelKind>> {
|
||||
match n {
|
||||
DockNode::Split { a, b, .. } => group_of(a, p).or_else(|| group_of(b, p)),
|
||||
DockNode::Tabs { panels, .. } => panels.contains(&p).then(|| panels.clone()),
|
||||
DockNode::Stack { entries } => entries.iter().any(|e| e.panel == p).then(|| entries.iter().map(|e| e.panel).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_comps(h: &Harness<'_, EffectcraftApp>) -> Vec<u64> {
|
||||
h.state().session.state.open_comps.iter().map(|c| c.0).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_double_click_opens_a_comp_as_its_own_timeline_tab() {
|
||||
let (mut h, pre, main) = harness();
|
||||
// Double-click the precomp's name in the Project panel: it opens (no rename) next to Main.
|
||||
let name = rect(&h, &format!("project.item.{pre}.name"));
|
||||
click_n(&mut h, name.center(), 2);
|
||||
assert_eq!(open_comps(&h), vec![main, pre]);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
|
||||
assert_eq!(h.state().session.project.item(ItemId(pre)).map(|i| i.name.clone()), Some("Pre".into()));
|
||||
// One Timeline tab per comp, the newest last; the shown one answers to the panel's id.
|
||||
let (tm, tp) = (rect(&h, &format!("panel.tab.Timeline.{main}")), rect(&h, &format!("panel.tab.Timeline.{pre}")));
|
||||
assert!(tp.min.x > tm.max.x, "{tm:?} {tp:?}");
|
||||
assert_eq!(rect(&h, "panel.tab.Timeline"), tp);
|
||||
// Clicking Main's tab shows Main again; both stay open.
|
||||
click_n(&mut h, tm.center(), 1);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(main)));
|
||||
assert_eq!(open_comps(&h), vec![main, pre]);
|
||||
// × closes the shown comp's Timeline only: the panel stays with Pre.
|
||||
let close = rect(&h, "panel.tab.Timeline.close");
|
||||
click_n(&mut h, close.center(), 1);
|
||||
assert_eq!(open_comps(&h), vec![pre]);
|
||||
assert_eq!(h.state().session.state.active_comp, Some(ItemId(pre)));
|
||||
assert!(h.state().ui.dock.contains(PanelKind::Timeline));
|
||||
// Double-clicking an open comp again adds no second tab.
|
||||
let name = rect(&h, &format!("project.item.{pre}.name"));
|
||||
click_n(&mut h, name.center(), 2);
|
||||
assert_eq!(open_comps(&h), vec![pre]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragged_tabs_land_where_the_insertion_mark_is() {
|
||||
let (mut h, _, _) = harness();
|
||||
let dock = |h: &Harness<'_, EffectcraftApp>| h.state().ui.dock.clone();
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::Timeline, PanelKind::RenderQueue]));
|
||||
// Reorder within the group: Render Queue dropped on the left half of the Timeline tab goes
|
||||
// before it.
|
||||
let tl = rect(&h, "panel.tab.Timeline");
|
||||
let rq = rect(&h, "panel.tab.RenderQueue");
|
||||
drag(&mut h, rq.center(), pos2(tl.min.x + tl.width() * 0.2, tl.center().y));
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline]));
|
||||
// Another group's tab dropped between them lands between them.
|
||||
let (rq, tl) = (rect(&h, "panel.tab.RenderQueue"), rect(&h, "panel.tab.Timeline"));
|
||||
let fx = rect(&h, "panel.tab.EffectControls");
|
||||
drag(&mut h, fx.center(), pos2((rq.max.x + tl.min.x) / 2.0 + 2.0, tl.center().y));
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::EffectControls, PanelKind::Timeline]));
|
||||
// Past the last tab: last.
|
||||
let (ec, tl) = (rect(&h, "panel.tab.EffectControls"), rect(&h, "panel.tab.Timeline"));
|
||||
drag(&mut h, ec.center(), pos2(tl.max.x + 30.0, tl.center().y));
|
||||
assert_eq!(group_of(&dock(&h), PanelKind::Timeline), Some(vec![PanelKind::RenderQueue, PanelKind::Timeline, PanelKind::EffectControls]));
|
||||
}
|
||||
|
||||
/// `cargo test -p effectcraft-ui-egui --test ui_tabs -- --ignored`: the insertion mark mid-drag
|
||||
/// (`target/test-out/tab-drag.png`).
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn tab_drag_snapshot() {
|
||||
let (mut h, _, _) = harness();
|
||||
let (tl, rq) = (rect(&h, "panel.tab.Timeline"), rect(&h, "panel.tab.RenderQueue"));
|
||||
let fx = rect(&h, "panel.tab.EffectControls");
|
||||
let to = pos2((tl.max.x + rq.min.x) / 2.0, tl.center().y);
|
||||
h.event(Event::PointerMoved(fx.center()));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: fx.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
for k in 1..=6 {
|
||||
h.event(Event::PointerMoved(fx.center() + (to - fx.center()) * (k as f32 / 6.0)));
|
||||
h.step();
|
||||
}
|
||||
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/test-out");
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
h.render().expect("render").save(format!("{dir}/tab-drag.png")).unwrap();
|
||||
}
|
||||
@@ -85,7 +85,9 @@ Points are logical window coordinates. A target is `{id}` (the element's centre;
|
||||
| `ui.screenshot` | `{path?, panel?, id?}` | `{path, width, height}`: a PNG of the window, or cropped to one panel or element. |
|
||||
|
||||
Element ids are stable, for example `tools.Selection`, `panel.Timeline`, `panel.tab.EffectControls`,
|
||||
`header.workspace.Animation`, `project.item.<id>`, `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`panel.tab.Timeline.<comp>` (one Timeline tab per open comp; `panel.tab.Timeline` is the shown
|
||||
one), `header.workspace.Animation`, `project.item.<id>` (a double-click opens the item, Enter
|
||||
renames it), `viewer.comp`, `viewer.handle.<layer>.<i>`,
|
||||
`timeline.layer.<id>.bar`, `timeline.layer.<id>.twirl`, `timeline.layer.<id>.row` (click selects,
|
||||
Enter or a double-click renames, drag reorders), `timeline.prop.<uid>.stopwatch`,
|
||||
`timeline.key.<uid>.<frame>`, `timeline.cti`, `effectControls.prop.<uid>.value` and
|
||||
|
||||
@@ -422,6 +422,12 @@ layers); the Graph Editor has Auto-Select Graph Type (the default: the speed gra
|
||||
spatial properties are shown, else the value graph; choosing Value or Speed turns it off). Graph
|
||||
Editor keys are small squares whatever their interpolation, as in After Effects.
|
||||
|
||||
Since (M3.15): the Timeline shows one tab per open comp, as in After Effects. Double-clicking a
|
||||
comp in the Project panel opens it as a new tab next to the comps already open; it no longer
|
||||
renames (Enter or the context menu still do). A tab's × closes that comp's Timeline, not the
|
||||
panel. A tab dragged over a tab strip shows an insertion mark between the tabs it will land
|
||||
between, and lands there; this reorders a group's own tabs too.
|
||||
|
||||
Since (M5.16): Key Light (After Effects' Keylight 1.2) is checked end to end on a green-screen
|
||||
plate with an uneven screen, spill and soft edges (`effect.apply "Keylight (1.2)"`, pick, Clip
|
||||
Black / White, render). The colour eyedroppers of Keying effects sample the effect's input
|
||||
|
||||
Reference in new issue
Block a user