From 40a498d6a2c73d649d53e7b92d3d21c4f79f3e48 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 05:36:59 -0400 Subject: [PATCH] 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 --- crates/ui-egui/src/header.rs | 4 +- crates/ui-egui/src/panels/project.rs | 25 +++--- crates/ui-egui/src/panels/timeline.rs | 111 ++++++++++++++++++-------- crates/ui-egui/src/panels/viewer.rs | 9 ++- crates/ui-egui/src/theme.rs | 2 +- crates/ui-egui/src/widgets.rs | 10 ++- 6 files changed, 111 insertions(+), 50 deletions(-) diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index b2fa142e..d185b52c 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -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; diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index f3898954..8c89f25f 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -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. diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 80c1a36c..9b3d6aac 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -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::(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()); + } } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 3d1f8de4..b8ec8d41 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index eb02bf48..288a04b5 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -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 { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 2af57f21..9703d6ba 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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 }