M13.5: UI fidelity pass (compact rows, flat rows with hairlines, switch wells, selected name cells, muted bars, AE ruler/work-area layout, twirl chevrons, timecode size, workspace bar order, single viewer breadcrumb row)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 05:36:59 -04:00
parent 1b823eb852
commit 40a498d6a2
6 changed files with 111 additions and 50 deletions

View File

@ -168,7 +168,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.set_workspace(crate::dock::WORKSPACES[i]);
}
rx = more.min.x - 6.0;
let shown = ["Small Screen", "Standard", "Animation", "Default"];
// After Effects 2026's workspace bar order (right to left here): Default, Review, Learn,
// Small Screen, Standard.
let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"];
for name in shown {
let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text);
let w = g.size().x + 16.0;

View File

@ -14,6 +14,9 @@ use crate::panels::DragPayload;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
/// Item row height (After Effects' Project panel rows are compact, about 19 pt).
const ROW_H: f32 = 19.0;
fn item_icon(it: &Item) -> Icon {
match &it.kind {
ItemKind::Folder => Icon::Folder,
@ -323,8 +326,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let mut actions: Vec<(String, serde_json::Value)> = vec![];
for (i, (id, depth)) in rows.iter().enumerate() {
let Some(it) = app.session.project.item(*id).cloned() else { continue };
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), 22.0));
y += 22.0;
let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
y += ROW_H;
if r.min.y > list.max.y {
break;
}
@ -335,14 +338,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
lp.rect_filled(
r,
0.0,
if selected {
t.row_selected
} else if i % 2 == 0 {
t.row
} else {
t.row_alt
},
// Flat rows like After Effects' Project panel (no stripes).
if selected { t.row_selected } else { t.row },
);
let _ = i;
let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
if it.is_folder() {
let open = app.ui.project_open_folders.contains(&id.0);
@ -400,12 +399,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
true
};
if !cell_edit(app, ui, &mut actions, "name", name_rect) {
// The selected item's name sits in a light cell with dark text.
if selected {
let g = lp.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), Color32::BLACK);
let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0));
lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6));
}
lp.with_clip_rect(name_clip).text(
pos2(x0 + 30.0, r.center().y),
Align2::LEFT_CENTER,
&it.name,
Tokens::ui(12.0),
if selected { Color32::WHITE } else { t.text },
if selected { Color32::from_gray(0x16) } else { t.text },
);
}
// Label swatch: click for the label colour menu.

View File

@ -47,6 +47,22 @@ enum RowKind {
/// Synthetic group uid for a layer's Audio > Waveform twirl (never a real property uid).
const WAVE_BIT: u64 = 1 << 60;
/// Time navigator's visible-span bar and the work area bar.
const NAV_BAR: Color32 = Color32::from_rgb(0x55, 0x55, 0x55);
const WORK_AREA_BAR: Color32 = Color32::from_rgb(0x5c, 0x5c, 0x5c);
/// Fill of the timeline's switch / A/V boxes (dark wells, as in After Effects).
const SWITCH_BOX: Color32 = Color32::from_rgb(0x19, 0x19, 0x19);
/// Background of the selected layer's name cell.
const SELECTED_NAME_BG: Color32 = Color32::from_rgb(0xa6, 0xa6, 0xa6);
/// A layer bar's fill: the label colour muted toward grey (a little brighter when selected).
pub(crate) fn bar_color(label: Color32, selected: bool) -> Color32 {
let k = if selected { 0.62 } else { 0.45 };
let g = 0x3a as f32;
let mix = |c: u8| (c as f32 * k + g * (1.0 - k)).round() as u8;
Color32::from_rgb(mix(label.r()), mix(label.g()), mix(label.b()))
}
/// Row height (expression editors grow with their text).
fn row_height(row: &Row, rh: f32) -> f32 {
match row.kind {
@ -756,7 +772,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let tc = crate::panels::timecode(&app.session, &comp, time);
let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
let tc_resp = ui.interact(tc_rect, egui::Id::new("tl-timecode"), Sense::click_and_drag());
p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(20.0), t.timecode);
p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
let sub = format!("{:05} ({:.2} fps)", fr.frame_at(time) + app.session.project.settings.frame_start, fr.as_f64());
p.text(pos2(tc_rect.min.x, tc_rect.max.y + 8.0), Align2::LEFT_CENTER, sub, Tokens::ui(10.5), t.text_faint);
app.auto.add("timeline.timecode", tc_rect, &tc);
@ -770,7 +786,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
if let Some(mut buf) = ctx.data(|d| d.get_temp::<String>(egui::Id::new("tl-tc-edit"))) {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(tc_rect));
let r = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::semibold(18.0)).desired_width(150.0));
let r = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::semibold(16.0)).desired_width(150.0));
r.request_focus();
if r.lost_focus() {
let _ = app.session.execute("time.set", json!({"timecode": buf}));
@ -810,14 +826,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// ---- ruler (right).
let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 12.0));
// Time navigator.
p.rect_filled(nav, 3.0, t.field_bg);
let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 14.0));
// Time navigator: the visible span as a grey bar with blue end handles (as in After
// Effects), over a dark track.
p.rect_filled(nav, 1.0, t.field_bg);
let vis0 = (tm.start / comp.duration.seconds()) as f32;
let vis1 = (tm.t(graph_x1) / comp.duration.seconds()) as f32;
let nav_vis =
Rect::from_min_max(pos2(nav.min.x + nav.width() * vis0.clamp(0.0, 1.0), nav.min.y), pos2(nav.min.x + nav.width() * vis1.clamp(0.0, 1.0), nav.max.y));
p.rect_filled(nav_vis, 3.0, t.work_area);
p.rect_filled(nav_vis, 0.0, NAV_BAR);
for x in [nav_vis.min.x, nav_vis.max.x] {
p.rect_filled(Rect::from_min_max(pos2(x - 3.0, nav.min.y - 1.0), pos2(x + 3.0, nav.max.y + 1.0)), 3.0, t.accent);
}
let nresp = ui.interact(nav, egui::Id::new("tl-nav"), Sense::drag());
if nresp.dragged() {
let d = nresp.drag_delta().x as f64 / nav.width() as f64 * comp.duration.seconds();
@ -828,12 +848,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps));
// Work area bar.
let wa = Rect::from_min_max(pos2(tm.x(comp.work_area.0.seconds()), ruler.min.y + 2.0), pos2(tm.x(comp.work_area.1.seconds()), ruler.min.y + 10.0));
p.rect_filled(wa, 2.0, t.work_area);
// Work area bar: below the ruler, beside the column headers (After Effects' layout), with
// blue begin / end handles; the cache bar runs under it.
let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
let wa = Rect::from_min_max(pos2(tm.x(comp.work_area.0.seconds()), wa_row.min.y + 3.0), pos2(tm.x(comp.work_area.1.seconds()), wa_row.max.y - 6.0));
p.with_clip_rect(wa_row).rect_filled(wa, 0.0, WORK_AREA_BAR);
for (hx, set) in [(wa.min.x, "begin"), (wa.max.x, "end")] {
let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 12.0));
p.rect_filled(Rect::from_center_size(pos2(hx, wa.center().y), vec2(3.0, 10.0)), 1.0, t.text_dim);
let hr = Rect::from_center_size(pos2(hx, wa.center().y), vec2(8.0, 14.0));
let handle = if set == "begin" {
Rect::from_min_max(pos2(hx - 1.0, wa.min.y), pos2(hx + 4.0, wa.max.y))
} else {
Rect::from_min_max(pos2(hx - 4.0, wa.min.y), pos2(hx + 1.0, wa.max.y))
};
p.rect_filled(handle, 2.0, t.accent);
let resp = ui.interact(hr, egui::Id::new(("wa", set)), Sense::drag());
app.auto.add(&format!("timeline.workArea.{set}"), hr, set);
if resp.dragged()
@ -848,7 +875,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let scale_key = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000);
let cached = app.frames.cached_frames(app.session.revision, cid.0, scale_key);
let fd = comp.frame_duration().seconds();
let cy0 = ruler.min.y + 11.0;
let cy0 = wa_row.max.y - 4.0;
let mut run: Option<(i64, i64)> = None;
let draw_run = |a: i64, b: i64| {
let x0 = tm.x(a as f64 * fd);
@ -1100,19 +1127,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let cy = r.min.y + rh / 2.0;
match &row.kind {
RowKind::Layer => {
lp.rect_filled(
left,
0.0,
if is_sel {
t.row_selected
} else if ri % 2 == 0 {
t.row
} else {
t.row_alt
},
);
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { Color32::from_rgb(0x26, 0x26, 0x26) } else { t.tl_bg });
lp.line_segment([pos2(rect.min.x, r.max.y), pos2(graph_x0, r.max.y)], Stroke::new(1.0, Color32::from_black_alpha(80)));
// After Effects draws layer rows as one flat tone separated by dark hairlines
// (outline and time graph alike), not as alternating stripes.
let _ = ri;
lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt });
gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { Color32::from_rgb(0x2a, 0x2a, 0x2a) } else { t.tl_bg });
lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
// A/V features.
let sw = &layer.switches;
let av_items: [(Icon, bool, &str, bool); 4] = [
@ -1122,11 +1143,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
(Icon::Lock, sw.locked, "lock", true),
];
for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() {
let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(17.0, 17.0));
let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0));
if !applicable || !vis.av {
continue;
}
lp.rect_stroke(br.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
lp.rect_filled(br.shrink(0.5), 1.0, SWITCH_BOX);
let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("av", layer.id.0, i)), None);
app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
if resp.clicked() {
@ -1176,7 +1197,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
}
} else {
let name_col = if is_sel { Color32::WHITE } else { t.text };
// The selected layer's name sits in a light cell with dark text.
let name_col = if is_sel { Color32::from_gray(0x16) } else { t.text };
if is_sel {
let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col);
let cell = Rect::from_min_max(
pos2(name_rect.min.x - 3.0, r.min.y + 1.0),
pos2((name_rect.min.x + g.size().x + 4.0).max(cw.name_end - 4.0).min(cw.name_end - 2.0), r.max.y - 1.0),
);
lp.rect_filled(cell, 0.0, SELECTED_NAME_BG);
}
let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
}
@ -1223,7 +1253,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
continue;
}
let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0));
lp.rect_stroke(br.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
lp.rect_filled(br.shrink(1.0), 1.0, SWITCH_BOX);
let resp = widgets::icon_toggle(ui, br, icon, on, &t, egui::Id::new(("sw", layer.id.0, i)), None);
app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
if resp.clicked() {
@ -1321,12 +1351,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
if !graph_on {
let x_in = tm.x(layer.in_point.seconds());
let x_out = tm.x(layer.out_point.seconds());
let bar = Rect::from_min_max(pos2(x_in, r.min.y + 3.0), pos2(x_out, r.max.y - 3.0));
let bar = Rect::from_min_max(pos2(x_in, r.min.y + 1.0), pos2(x_out, r.max.y - 1.0));
let lc = t.label(layer.label);
let fill = if is_sel { lc.gamma_multiply(0.85) } else { lc.gamma_multiply(0.55) };
gp.rect_filled(bar, 2.0, fill);
let fill = bar_color(lc, is_sel);
gp.rect_filled(bar, 0.0, fill);
if is_sel {
gp.rect_stroke(bar, 2.0, Stroke::new(1.0, Color32::from_white_alpha(120)), StrokeKind::Inside);
gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside);
}
// Source extent (e.g. trimmed footage) shown faint.
if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source
@ -1437,7 +1467,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
if let Some(en) = eye {
// Layer style eye switch in the A/V column, like AE.
let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(17.0, 17.0));
let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::new(("geye", uid)), None).clicked() {
actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
@ -2610,4 +2640,17 @@ mod tests {
let c = snap_candidates(&comp, &build_rows(&app, &comp));
assert!(c.contains(&comp.duration.seconds()) && c.contains(&0.0));
}
/// UI fidelity (M13.5): compact After Effects-like rows and muted label-coloured bars.
#[test]
fn rows_and_bars_follow_after_effects_proportions() {
let t = crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark);
assert_eq!(t.row_h, 19.0);
let red = t.label(effectcraft_engine::color::Label::Red);
let bar = bar_color(red, false);
// Muted: darker and less saturated than the label, still reddish.
assert!(bar.r() < red.r() && bar.r() > bar.g() + 30 && bar.g() >= red.g().min(0x3a) - 1);
let sel = bar_color(red, true);
assert!(sel.r() > bar.r());
}
}

View File

@ -404,11 +404,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let p = ui.painter().clone();
VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
// Navigator bar (open comps as breadcrumbs).
let nav = Rect::from_min_size(rect.min, vec2(rect.width(), 24.0));
// Navigator bar (open comps as breadcrumbs). After Effects shows a single breadcrumb row:
// when the Composition Navigator (nested comp flow) is above the viewer, this row is left
// out.
let nav_h = if super::precomp::has_flow(app) { 0.0 } else { 24.0 };
let nav = Rect::from_min_size(rect.min, vec2(rect.width(), nav_h));
p.rect_filled(nav, 0.0, t.panel_bg);
let mut x = nav.min.x + 10.0;
let open = app.session.state.open_comps.clone();
let open = if nav_h > 0.0 { app.session.state.open_comps.clone() } else { vec![] };
for oc in open {
let name = app.session.project.item(oc).map(|i| i.name.clone()).unwrap_or_default();
let g = p.layout_no_wrap(name.clone(), Tokens::ui(11.5), t.text);

View File

@ -145,7 +145,7 @@ impl Tokens {
radius_sm: 3.0,
gap: 4.0,
tab_h: 30.0,
row_h: 22.0,
row_h: 19.0,
gradients: true,
};
match kind {

View File

@ -87,7 +87,15 @@ pub fn swatch(ui: &mut Ui, rect: Rect, c: [f32; 4], id: egui::Id, t: &Tokens) ->
pub fn twirl(ui: &mut Ui, rect: Rect, open: bool, id: egui::Id, t: &Tokens) -> Response {
let resp = ui.interact(rect, id, Sense::click());
let col = if resp.hovered() { t.tab_text_active } else { t.text_dim };
icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col);
// An open chevron (> / v), as After Effects draws its twirl-downs.
let c = rect.center();
let k = rect.width().min(rect.height()) * 0.2;
let pts = if open {
vec![c + vec2(-1.6 * k, -0.8 * k), c + vec2(0.0, 0.8 * k), c + vec2(1.6 * k, -0.8 * k)]
} else {
vec![c + vec2(-0.8 * k, -1.6 * k), c + vec2(0.8 * k, 0.0), c + vec2(-0.8 * k, 1.6 * k)]
};
ui.painter().add(egui::Shape::line(pts, Stroke::new(1.4, col)));
resp
}