Merge pull request #49 from storytold/timeline-comp-tabs

M3.15: Timeline tabs per open comp; Project double-click opens; tab drag insertion mark
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 11:59:07 +07:00
commit 9b5652f686
8 files changed
+483 -81

No files matched your search

+184 -42
View File
@@ -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
View File
@@ -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();
+5 -2
View File
@@ -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;
}
+4 -5
View File
@@ -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) => {
+4 -1
View File
@@ -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));
}
+146
View File
@@ -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();
}
+3 -1
View File
@@ -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
+6
View File
@@ -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