From 6e72ab3b57eb62e8dc32f62e64c0fc949f31c1af Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH] Refactor ui-egui for strict Clippy checks --- crates/ui-egui/src/app/files.rs | 2 +- crates/ui-egui/src/app/gpu.rs | 3 +- crates/ui-egui/src/app/prefetch.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 3 +- crates/ui-egui/src/app/screenshot.rs | 2 +- crates/ui-egui/src/header.rs | 337 +- crates/ui-egui/src/menus/bar.rs | 2 +- crates/ui-egui/src/menus/commands.rs | 2 +- crates/ui-egui/src/menus/frontend.rs | 2 +- crates/ui-egui/src/menus/shortcuts.rs | 141 +- crates/ui-egui/src/panels/anim_tools.rs | 72 +- .../src/panels/effect_controls/common.rs | 2 +- .../src/panels/effect_controls/editors.rs | 2 +- .../src/panels/effect_controls/panel.rs | 231 +- .../src/panels/effect_controls/rows.rs | 612 ++- .../src/panels/effect_controls/viewer.rs | 279 +- crates/ui-egui/src/panels/effects_presets.rs | 55 +- crates/ui-egui/src/panels/expr_editor.rs | 10 +- crates/ui-egui/src/panels/forms.rs | 544 +-- crates/ui-egui/src/panels/fx_editors.rs | 20 +- crates/ui-egui/src/panels/fx_widgets.rs | 3 +- crates/ui-egui/src/panels/graph.rs | 275 +- crates/ui-egui/src/panels/key_dialogs.rs | 20 +- crates/ui-egui/src/panels/layer_panel.rs | 239 +- crates/ui-egui/src/panels/media_panels.rs | 38 +- crates/ui-egui/src/panels/misc.rs | 28 +- crates/ui-egui/src/panels/paint_panels.rs | 112 +- crates/ui-egui/src/panels/panel_kit.rs | 2 +- crates/ui-egui/src/panels/project.rs | 1129 +++-- crates/ui-egui/src/panels/properties.rs | 39 +- crates/ui-egui/src/panels/render_queue.rs | 355 +- crates/ui-egui/src/panels/roto_tool.rs | 18 +- crates/ui-egui/src/panels/settings.rs | 39 +- crates/ui-egui/src/panels/text_panels.rs | 195 +- crates/ui-egui/src/panels/timeline.rs | 4148 +++++++++++------ crates/ui-egui/src/panels/tracker.rs | 52 +- crates/ui-egui/src/panels/viewer.rs | 556 ++- crates/ui-egui/src/panels/viewer/frame.rs | 2 +- crates/ui-egui/src/panels/viewer/geometry.rs | 2 +- crates/ui-egui/src/panels/viewer/gesture.rs | 2 +- crates/ui-egui/src/panels/viewer/shapes.rs | 5 +- crates/ui-egui/src/panels/viewer/tools.rs | 2 +- crates/ui-egui/src/panels/viewer/view.rs | 2 +- crates/ui-egui/src/panels/viewer_text.rs | 380 +- crates/ui-egui/src/panels/viewer_tools.rs | 62 +- crates/ui-egui/src/pointer_wrap/tests.rs | 5 +- crates/ui-egui/src/widgets.rs | 25 +- 47 files changed, 6246 insertions(+), 3812 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index da6aef559..b1beab972 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, EffectcraftApp}; impl EffectcraftApp { /// Show the crash-recovery dialog for a previous run that didn't exit cleanly. diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 6b486997e..9febfa413 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels}; impl EffectcraftApp { pub fn render_source(&self) -> RenderSource { @@ -184,6 +184,7 @@ impl EffectcraftApp { #[cfg(test)] mod gpu_failure_tests { use super::*; + use crate::{FrameKey, ItemId, RenderOpts, Session, Tick, json}; #[test] fn host_failure_preserves_document_undo_and_cpu_pixels_and_detaches_late_readbacks() { diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index b55884117..1fdf0aad1 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames}; /// Seconds without a project change before paused prefetch resumes (see /// [`EffectcraftApp::editing`]). diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 5ccb3ca18..6fc424e01 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, Tokens, json, theme}; impl EffectcraftApp { /// Apply changed settings: theme and brightness, label colours, tool tips, preview caches. @@ -67,6 +67,7 @@ impl EffectcraftApp { #[cfg(test)] mod appearance_tests { use super::*; + use crate::menus; use crate::theme::ThemeKind; fn app() -> EffectcraftApp { diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index 12092f2f3..a80a76bd6 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DrawGeometry, EffectcraftApp, PendingShot, Sender, ShotTarget, control, json}; /// How long a screenshot request waits for a frame, from admission (app clock seconds). pub(crate) const SCREENSHOT_TIMEOUT_S: f64 = 8.0; diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 861d6fd91..3eee89453 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -25,11 +25,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(rect, 0.0, t.header_bg); p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.app_bg)); let cy = rect.center().y; - let mut x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 }; + let x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 }; + let x = brand_and_home(app, ui, &p, x, cy); + let x = tool_slots(app, ui, &p, x, cy); + let x = tool_options_bar(app, ui, &p, x + 10.0, cy); + let x = snapping_toggle(app, ui, &p, x, cy); + right_side(app, ui, &p, rect, x, cy); +} - // Brand mark. +/// The brand mark (About) and the Home button; returns the x after them. +fn brand_and_home(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0)); - paint_logo(&p, brand); + paint_logo(p, brand); let bresp = ui.interact(brand, egui::Id::unique("brand"), Sense::click()); app.auto.add_response("header.about", &bresp, "About EffectCraft"); widgets::focus_outline(ui, &bresp, &t); @@ -38,7 +46,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } x += 32.0; - // Home. let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0)); let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::unique("tool-home")).on_hover_text(tr("Home")); app.auto.add_response("header.home", &home_response, "Home"); @@ -47,78 +54,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } x += 30.0; p.line_segment([pos2(x, cy - 10.0), pos2(x, cy + 10.0)], Stroke::new(1.0, t.separator)); - x += 6.0; + x + 6.0 +} - // Tool slots (with group separators after camera tools and pan-behind). +/// The tool slots (with group separators after camera tools and pan-behind); returns the x after them. +fn tool_slots(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; let held_id = egui::Id::unique("tool-slot-held"); if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } for (si, slot) in Tool::SLOTS.iter().enumerate() { - let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]); let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0)); - let active = slot.contains(&app.ui.tool); - let id = egui::Id::unique(("tool-slot", si)); - let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id); - if slot.len() > 1 { - // Flyout corner triangle. - p.add(egui::Shape::convex_polygon( - vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)], - if active { Color32::WHITE } else { t.text_dim }, - Stroke::NONE, - )); - } - app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); - let tip = match cur.shortcut() { - Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), - None => cur.label().to_string(), - }; - let resp = resp.on_hover_text(tip); - let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); - if resp.clicked() && !held { - app.ui.tool = cur; - } - // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. - if cur == Tool::PanBehind - && resp.double_clicked() - && ui.input(|i| i.modifiers.command) - && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) - { - app.ui.status = e; - } - if slot.len() > 1 { - let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); - if let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held; - let command = if resp.secondary_clicked() || long_press { - if long_press { - ui.data_mut(|d| d.insert_temp(held_id, id)); - } - Some(egui::SetOpenCommand::Bool(true)) - } else if resp.clicked() && !held { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; - let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; - egui::Popup::menu(&resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { - for tool in slot.iter() { - let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); - app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); - if row.clicked() { - app.ui.tool = *tool; - if let Some(saved) = app.ui.slot_tools.get_mut(si) { - *saved = *tool; - } - ui.close(); - } - } - }); - } + tool_slot(app, ui, p, si, slot, r, held_id); x += 28.0; if matches!(si, 2 | 5 | 7 | 10 | 13) { x += 4.0; @@ -126,17 +74,96 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { x += 6.0; } } + x +} - // Tool options. - x += 10.0; - // Selected shape layers: the options show their paint and edit it (as in After Effects). - let shape_layers: Vec = app - .session +/// One tool slot button in `r`: select on click, Ctrl+double-click Pan Behind, and the fly-out. +fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: usize, slot: &[Tool], r: Rect, held_id: egui::Id) { + let t = app.tokens; + let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]); + let active = slot.contains(&app.ui.tool); + let id = egui::Id::unique(("tool-slot", si)); + let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id); + if slot.len() > 1 { + // Flyout corner triangle. + p.add(egui::Shape::convex_polygon( + vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)], + if active { Color32::WHITE } else { t.text_dim }, + Stroke::NONE, + )); + } + app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); + let tip = match cur.shortcut() { + Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), + None => cur.label().to_string(), + }; + let resp = resp.on_hover_text(tip); + let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); + if resp.clicked() && !held { + app.ui.tool = cur; + } + // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. + if cur == Tool::PanBehind + && resp.double_clicked() + && ui.input(|i| i.modifiers.command) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) + { + app.ui.status = e; + } + if slot.len() > 1 { + slot_flyout(app, ui, si, slot, &resp, held_id); + } +} + +/// The fly-out menu of a multi-tool slot: right-click or press-and-hold opens it. +fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[Tool], resp: &egui::Response, held_id: egui::Id) { + let id = resp.id; + let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); + let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held; + let command = if resp.secondary_clicked() || long_press { + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, id)); + } + Some(egui::SetOpenCommand::Bool(true)) + } else if resp.clicked() && !held { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; + egui::Popup::menu(resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { + for tool in slot.iter() { + let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); + app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); + if row.clicked() { + app.ui.tool = *tool; + if let Some(saved) = app.ui.slot_tools.get_mut(si) { + *saved = *tool; + } + ui.close(); + } + } + }); +} + +/// The ids of the selected shape layers: the options show their paint and edit it. +fn selected_shape_layers(app: &EffectcraftApp) -> Vec { + app.session .active_comp() .map(|c| { app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect() }) - .unwrap_or_default(); + .unwrap_or_default() +} + +/// Mirrors the first selected shape layer's paint into the shape tool (as in After Effects). +fn sync_layer_paint(app: &mut EffectcraftApp, shape_layers: &[u64]) { let layer_paint = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(|l| { let lt = l.layer_time(app.session.time()); (effectcraft_engine::commands::shape_stroke::first_paint(l, lt), effectcraft_engine::commands::shape_stroke::first_paint_kinds(l, lt)) @@ -154,6 +181,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } tool.stroke_width = width.unwrap_or(0.0); } +} + +/// Options for the active tool (shape creation, fill and stroke, puppet, roto); returns the x after them. +fn tool_options_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + // Selected shape layers: the options show their paint and edit it (as in After Effects). + let shape_layers = selected_shape_layers(app); + sync_layer_paint(app, &shape_layers); // One undo step per picker session or width drag: end the merge once both are done. let picking = ["fill", "stroke"].iter().any(|k| widgets::popup_is_open(ui, picker_id(k))); if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) { @@ -167,14 +201,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Fill and Stroke, hidden while the tools draw masks. let masking = drawing && !shape_layers.is_empty() && app.session.state.shape_tool.creates_mask; if (drawing || !shape_layers.is_empty()) && !masking { - x = fill_stroke_options(app, ui, &p, &shape_layers, x, cy); + x = fill_stroke_options(app, ui, p, &shape_layers, x, cy); } if app.ui.tool.puppet_kind().is_some() { - x = puppet_options(app, ui, &p, x, cy); + x = puppet_options(app, ui, p, x, cy); } if app.ui.tool.roto_kind(false).is_some() { - x = roto_options(app, ui, &p, x, cy); + x = roto_options(app, ui, p, x, cy); } + x +} + +/// The Snapping checkbox, its label and options menu; returns the x where it started. +fn snapping_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: f32, cy: f32) -> f32 { + let t = app.tokens; let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; @@ -185,9 +225,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("header.snapping", snap, "Snapping"); let label = p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, tr("Snapping"), Tokens::ui(12.0), t.text_dim); snapping_options(app, ui, Rect::from_min_size(pos2(label.max.x + 2.0, cy - 9.0), vec2(18.0, 18.0))); + x +} - // Right side: community buttons, workspaces. - let mut rx = rect.max.x - 10.0; +/// Right side: community buttons, appearance and workspaces; `left` is where the tools end. +fn right_side(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rect: Rect, left: f32, cy: f32) { + let t = app.tokens; + let rx = community_buttons(app, ui, p, rect.max.x - 10.0, cy); + let mut rx = appearance_button(app, ui, rx, cy); + rx -= 10.0; + p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); + rx -= 10.0; + let rx = workspace_menu(app, ui, p, rx, cy); + workspace_tabs(app, ui, p, rx, left, cy); +} + +/// The Discord pill and the GitHub and website buttons, right to left from `rx`; returns the new `rx`. +fn community_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, cy: f32) -> f32 { + let t = app.tokens; // The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the // text at its left, which left a wide gap on the right (#581). let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE); @@ -195,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(discord, egui::Id::unique("hdr-discord"), Sense::click()); let dc = Color32::from_rgb(0x58, 0x65, 0xf2); p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc }); - icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); + icons::paint(p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE); app.auto.add("header.discord", discord, "Join the ArtCraft Discord"); if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() { @@ -212,7 +267,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("header.{}", &id[4..]), r, tip); rx = r.min.x - 4.0; } - // Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next. + rx +} + +/// Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next; +/// returns the new `rx`. +fn appearance_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, rx: f32, cy: f32) -> f32 { + let t = app.tokens; let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() { "auto" => (Icon::Monitor, tr("Sync with System")), "light" => (Icon::Sun, tr("Light")), @@ -224,37 +285,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if response.clicked() { app.cycle_appearance(ui.ctx()); } - response.context_menu(|ui| { - let settings = ui.button(tr("Appearance Settings…")); - app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); - if settings.clicked() { - crate::panels::settings::open(app, "appearance"); - ui.close(); - } - let favorites = app.session.prefs.appearance.favorite_themes.clone(); - for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { - let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); - if !group.is_empty() { - ui.separator(); - } - for (label, id) in group { - let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); - app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); - if r.clicked() { - if let Some(kind) = crate::theme::ThemeKind::from_name(id) { - app.set_theme(ui.ctx(), kind); - } - ui.close(); - } - } - } - }); + response.context_menu(|ui| appearance_menu(app, ui)); app.auto.add("header.appearance", r, &tip); - rx = r.min.x - 4.0; - rx -= 10.0; - p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); - rx -= 10.0; - // Workspace tabs (right to left), with a » menu of all. + r.min.x - 4.0 +} + +/// The appearance button's context menu: settings and the favorite themes. +fn appearance_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { + let settings = ui.button(tr("Appearance Settings…")); + app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); + if settings.clicked() { + crate::panels::settings::open(app, "appearance"); + ui.close(); + } + let favorites = app.session.prefs.appearance.favorite_themes.clone(); + for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { + let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); + if !group.is_empty() { + ui.separator(); + } + for (label, id) in group { + let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); + app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); + if r.clicked() { + if let Some(kind) = crate::theme::ThemeKind::from_name(id) { + app.set_theme(ui.ctx(), kind); + } + ui.close(); + } + } + } +} + +/// The » button with the menu of all workspaces; returns the new `rx`. +fn workspace_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rx: f32, cy: f32) -> f32 { + let t = app.tokens; let more = Rect::from_min_max(pos2(rx - 20.0, cy - 11.0), pos2(rx, cy + 11.0)); let mresp = ui.interact(more, egui::Id::unique("ws-more"), Sense::click()); p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim }); @@ -273,15 +338,19 @@ 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; - // After Effects 2026's workspace bar order (right to left here): Default, Review, Learn, - // Small Screen, Standard. + more.min.x - 6.0 +} + +/// Workspace tabs, right to left from `rx`, stopping 120 px right of `left`. +/// After Effects 2026's order (right to left here): Default, Review, Learn, Small Screen, Standard. +fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, left: f32, cy: f32) { + let t = app.tokens; let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"]; for name in shown { let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text); let w = g.size().x + 16.0; let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0)); - if r.min.x < x + 120.0 { + if r.min.x < left + 120.0 { break; } let active = app.ui.workspace == name; @@ -439,7 +508,14 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa x = sr.max.x + 12.0; } let width = app.session.state.shape_tool.stroke_width; - let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::unique("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t); + let (r, v, _) = widgets::hot_number_at( + ui, + pos2(x - 6.0, cy - 9.0), + egui::Id::unique("tool-stroke-w"), + width, + &widgets::HotSpec { speed: 0.2, range: (0.0, 1000.0), decimals: 0, suffix: " px" }, + &t, + ); if let Some(v) = v { tool_options(app, json!({"strokeWidth": v})); paint(app, "strokeWidth", json!(v)); @@ -506,7 +582,14 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers } app.auto.add(&format!("header.{key}Options.blend"), blend_rect, "Blend Mode"); p.text(row(98.0), Align2::LEFT_CENTER, tr("Opacity:"), Tokens::ui(12.0), t.text_dim); - let (or, v, _) = widgets::hot_number_at(ui, row(89.0) + vec2(84.0, 0.0), id.with("opacity"), tp.opacity, 0.5, (0.0, 100.0), 0, "%", &t); + let (or, v, _) = widgets::hot_number_at( + ui, + row(89.0) + vec2(84.0, 0.0), + id.with("opacity"), + tp.opacity, + &widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "%" }, + &t, + ); app.auto.add(&format!("header.{key}Options.opacity"), or, "Opacity"); if let Some(v) = v { let k_opacity = format!("{key}Opacity"); @@ -577,7 +660,14 @@ fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let label = tr("Diameter:"); let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); x = lr.max.x + 6.0; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t); + let (r, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("roto-opt", key)), + v, + &widgets::HotSpec { speed: 0.5, range: (1.0, 5000.0), decimals: 0, suffix: " px" }, + &t, + ); app.auto.add("header.roto.diameter", r, label); if let Some(nv) = nv && let Err(e) = app.session.execute("roto.options", json!({key: nv})) @@ -604,7 +694,14 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] { p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); x += if key == "expansion" { 64.0 } else { 52.0 }; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("puppet-opt", key)), v, 0.2, range, 0, "", &t); + let (r, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("puppet-opt", key)), + v, + &widgets::HotSpec { speed: 0.2, range, decimals: 0, suffix: "" }, + &t, + ); app.auto.add(&format!("header.puppet.{key}"), r, label); if let Some(nv) = nv { let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")})); diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 1a7f6e2a3..e48bae5c8 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -1,7 +1,7 @@ use super::entries::{entry_checked, entry_enabled, entry_label, entry_shortcut, menu_choice}; use super::invoke::invoke; use super::shortcuts::shortcut_text; -use super::*; +use super::{EffectcraftApp, MenuEntry, MenuNode, Value, json}; /// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last /// frame (its title's open look, and the hover that switches between the titles). diff --git a/crates/ui-egui/src/menus/commands.rs b/crates/ui-egui/src/menus/commands.rs index bc695f609..c162bbd3a 100644 --- a/crates/ui-egui/src/menus/commands.rs +++ b/crates/ui-egui/src/menus/commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::UiCommand; macro_rules! uic { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr) => { diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index d808b4620..f536086d9 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, PanelKind, Resolution, Value, json}; /// Run an engine command and perform the frontend events it emitted right away. /// Commands that fill the internal clipboard. diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index 7e3dfcec2..b9990786c 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -1,6 +1,6 @@ use super::frontend::run_engine; use super::invoke::invoke; -use super::*; +use super::{EffectcraftApp, PanelKind, Value, json}; /// Shortcut text in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere). pub fn shortcut_text(s: &str) -> String { @@ -197,54 +197,40 @@ pub fn handle_tab_shortcut(app: &mut EffectcraftApp, ctx: &egui::Context) { } } -fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) { - let start = modifiers_at_start(ctx); - // Dialog cancellation owns Escape even when a text field has keyboard focus. - if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - // A popup or colour picker takes Escape first (the picker goes back to its original colour). - if crate::panels::shortcut_editor::recording(app) - || crate::panels::shortcut_editor::confirming(app) - || egui::Popup::is_any_open(ctx) - || crate::color_picker::open_recently(ctx) +/// Escape with a dialog open: close the popup/picker first, else cancel the dialog. +fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { + // A popup or colour picker takes Escape first (the picker goes back to its original colour). + if crate::panels::shortcut_editor::recording(app) + || crate::panels::shortcut_editor::confirming(app) + || egui::Popup::is_any_open(ctx) + || crate::color_picker::open_recently(ctx) + { + return; + } + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if app.dialog == Some(crate::Dialog::LayerStyles) { + crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + } else { + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() { - return; + app.session.prefs = p; + app.session.prefs_changed(); } - ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); - if app.dialog == Some(crate::Dialog::LayerStyles) { - crate::panels::layer_styles_dialog::finish(app, false); - } else if app.dialog == Some(crate::Dialog::Info) { - crate::panels::forms::close_info(app); - } else { - if app.dialog == Some(crate::Dialog::Settings) - && let Some(p) = app.dialog_state.prefs_snapshot.take() - { - app.session.prefs = p; - app.session.prefs_changed(); - } - if app.dialog == Some(crate::Dialog::UnsavedChanges) { - app.dialog_state.unsaved.command = None; - } - app.dialog = None; + if app.dialog == Some(crate::Dialog::UnsavedChanges) { + app.dialog_state.unsaved.command = None; } - return; + app.dialog = None; } - // A mouse button down while Spacebar is held belongs to the Hand tool: its release won't - // preview. - if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { - ctx.data_mut(|d| d.remove::(space_tap_id())); - } - // While a menu or popup list is open, keys go to it, not to the app (Space must not start a - // preview behind the menu, #279). - if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) { - return; - } - // Text editing in the viewer takes the clipboard events itself. - let clipboard = app.session.state.text_edit.is_none(); - let project = app.ui.focused == PanelKind::Project; - // The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer. - let pasted = - if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None }; - let mut events: Vec<(egui::Key, egui::Modifiers, bool, Option)> = ctx.input(|i| { +} + +type KeyEvent = (egui::Key, egui::Modifiers, bool, Option); + +/// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V. +fn collect_key_events(ctx: &egui::Context, start: Option, clipboard: bool, project: bool) -> Vec { + ctx.input(|i| { // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, // Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and @@ -283,22 +269,57 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } } out - }); + }) +} + +/// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it +/// comes as text. +fn numpad_star_marker(app: &mut EffectcraftApp, ctx: &egui::Context) { + if !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { + return; + } + let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); + // As in After Effects, it marks the selected layers; only with none selected does it add + // a composition marker (#976). + let bound = + bound.map( + |(id, params)| { + if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } + }, + ); + if let Some((id, params)) = bound + && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) + { + app.ui.status = e; + } +} + +fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) { + let start = modifiers_at_start(ctx); + if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + escape_dialog(app, ctx); + return; + } + // A mouse button down while Spacebar is held belongs to the Hand tool: its release won't + // preview. + if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { + ctx.data_mut(|d| d.remove::(space_tap_id())); + } + // While a menu or popup list is open, keys go to it, not to the app (Space must not start a + // preview behind the menu, #279). + if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) { + return; + } + // Text editing in the viewer takes the clipboard events itself. + let clipboard = app.session.state.text_edit.is_none(); + let project = app.ui.focused == PanelKind::Project; + // The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer. + let pasted = + if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None }; + let mut events = collect_key_events(ctx, start, clipboard, project); events.retain(|(key, _, _, _)| (*key == egui::Key::Tab) == tab_only); - // Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it - // comes as text. - if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { - let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); - // As in After Effects, it marks the selected layers; only with none selected does it add - // a composition marker (#976). - let bound = bound.map(|(id, params)| { - if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } - }); - if let Some((id, params)) = bound - && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) - { - app.ui.status = e; - } + if !tab_only && clipboard && app.dialog.is_none() { + numpad_star_marker(app, ctx); } if events.is_empty() { return; diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index fe4c67895..b07da8567 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -33,9 +33,9 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti /// A drag-to-change number field for an anim-tool setting: `v` clamped to `range`, `speed` points /// per pixel, `dec` decimals and a `suffix`; returns the new value, or `v` when it did not change. -fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 { +fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, spec: widgets::HotSpec) -> f64 { let t = app.tokens; - let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, speed, range, dec, suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, &spec, &t); app.auto.add(auto, r, &format!("{v}")); nv.unwrap_or(v) } @@ -105,10 +105,24 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += ROW + 4.0; label(&p, x0, y, "Frequency:", &t); - st.wiggle_frequency = number(app, ui, pos2(xv, y), "wiggler.frequency", st.wiggle_frequency, 0.1, (0.1, 100.0), 1, " per second"); + st.wiggle_frequency = number( + app, + ui, + pos2(xv, y), + "wiggler.frequency", + st.wiggle_frequency, + widgets::HotSpec { speed: 0.1, range: (0.1, 100.0), decimals: 1, suffix: " per second" }, + ); y += ROW; label(&p, x0, y, "Magnitude:", &t); - st.wiggle_magnitude = number(app, ui, pos2(xv, y), "wiggler.magnitude", st.wiggle_magnitude, 0.5, (0.0, 100_000.0), 1, ""); + st.wiggle_magnitude = number( + app, + ui, + pos2(xv, y), + "wiggler.magnitude", + st.wiggle_magnitude, + widgets::HotSpec { speed: 0.5, range: (0.0, 100_000.0), decimals: 1, suffix: "" }, + ); y += ROW + 8.0; app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); @@ -149,7 +163,14 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, txt, Tokens::ui(12.0), t.text); y += ROW; label(&p, x0, y, "Tolerance:", &t); - let tol = number(app, ui, pos2(xv, y), "smoother.tolerance", app.ui.anim_tools.smooth_tolerance, 0.05, (0.0, 10_000.0), 2, ""); + let tol = number( + app, + ui, + pos2(xv, y), + "smoother.tolerance", + app.ui.anim_tools.smooth_tolerance, + widgets::HotSpec { speed: 0.05, range: (0.0, 10_000.0), decimals: 2, suffix: "" }, + ); app.ui.anim_tools.smooth_tolerance = tol; y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); @@ -170,10 +191,24 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = rect.min.y + 10.0; let mut st = app.ui.anim_tools.clone(); label(&p, x0, y, "Capture speed at:", &t); - st.sketch_speed = number(app, ui, pos2(xv, y), "motionSketch.captureSpeed", st.sketch_speed, 1.0, (1.0, 10_000.0), 0, " %"); + st.sketch_speed = number( + app, + ui, + pos2(xv, y), + "motionSketch.captureSpeed", + st.sketch_speed, + widgets::HotSpec { speed: 1.0, range: (1.0, 10_000.0), decimals: 0, suffix: " %" }, + ); y += ROW; label(&p, x0, y, "Smoothing:", &t); - st.sketch_smoothing = number(app, ui, pos2(xv, y), "motionSketch.smoothing", st.sketch_smoothing, 0.1, (0.0, 100.0), 1, ""); + st.sketch_smoothing = number( + app, + ui, + pos2(xv, y), + "motionSketch.smoothing", + st.sketch_smoothing, + widgets::HotSpec { speed: 0.1, range: (0.0, 100.0), decimals: 1, suffix: "" }, + ); y += ROW + 2.0; p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, tr("Show:"), Tokens::ui(12.0), t.text_dim); y += ROW - 6.0; @@ -328,7 +363,14 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), if i == 0 { json!("auto") } else { json!(fps) }); } if let Some(r) = o.keyframe_rate { - let nr = number(app, ui, pos2(xv + 78.0, y), "maskInterp.rate", r, 0.5, (0.1, 1000.0), 1, " per second"); + let nr = number( + app, + ui, + pos2(xv + 78.0, y), + "maskInterp.rate", + r, + widgets::HotSpec { speed: 0.5, range: (0.1, 1000.0), decimals: 1, suffix: " per second" }, + ); if (nr - r).abs() > 1e-9 { changes.insert("keyframeRate".into(), json!(nr)); } @@ -346,13 +388,20 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec check(app, y, "linearVertexPaths", "Use \"Linear\" Vertex Paths", o.linear_vertex_paths); y += ROW; label(&p, x0, y, "Bending Resistance:", &t); - let br = number(app, ui, pos2(xv, y), "maskInterp.bendingResistance", o.bending_resistance, 0.5, (0.0, 100.0), 0, ""); + let br = number( + app, + ui, + pos2(xv, y), + "maskInterp.bendingResistance", + o.bending_resistance, + widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" }, + ); if (br - o.bending_resistance).abs() > 1e-9 { changes.insert("bendingResistance".into(), json!(br)); } y += ROW; label(&p, x0, y, "Quality:", &t); - let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, 0.5, (0.0, 100.0), 0, ""); + let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" }); if (q - o.quality).abs() > 1e-9 { changes.insert("quality".into(), json!(q)); } @@ -363,7 +412,8 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } y += ROW; if let Some(v) = o.add_vertices { - let nv = number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, 0.2, (0.1, 2000.0), 1, ""); + let nv = + number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, widgets::HotSpec { speed: 0.2, range: (0.1, 2000.0), decimals: 1, suffix: "" }); if (nv - v).abs() > 1e-9 { changes.insert("addVertices".into(), json!(nv)); } diff --git a/crates/ui-egui/src/panels/effect_controls/common.rs b/crates/ui-egui/src/panels/effect_controls/common.rs index b96ed2180..ab46a07ac 100644 --- a/crates/ui-egui/src/panels/effect_controls/common.rs +++ b/crates/ui-egui/src/panels/effect_controls/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, Layer, Node, ParamUi, PropGroup, Value, json}; pub(super) type Actions = Vec<(String, serde_json::Value)>; diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index 3e79c7bdd..c95c90c07 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -1,5 +1,5 @@ use super::common::{Actions, gesture_key}; -use super::*; +use super::{Align2, Color32, EffectcraftApp, EvalCtx, Layer, PropGroup, Rect, Sense, Tokens, Value, fw, json, pos2, vec2, widgets}; /// Warp Stabilizer's Analyze / Cancel buttons and analysis status. pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) { diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 30bb5804f..39cdee76c 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -1,7 +1,7 @@ use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, selected_layer}; use super::editors::{curves_editor, editor_height, histogram_only, levels_editor, object_matte_editor, roto_editor, warp_editor}; use super::rows::{FxScope, group_rows, toggle_closed}; -use super::*; +use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets}; // --------------------------------------------------------------------------------------------- @@ -13,6 +13,140 @@ pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) { ctx.memory_mut(|m| m.request_focus(id.with("field"))); } +/// Where an effect header's parts sit, for its rename field. +struct EffectHdr<'a> { + g: &'a PropGroup, + shown: &'a str, + r: Rect, + tw: Rect, + reset: Rect, + rename_id: egui::Id, + layer_id: u64, +} + +/// The effect header's name field while renaming: Enter or blur commits, Escape cancels. +fn rename_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, h: &EffectHdr, mut value: String, actions: &mut Actions) { + let EffectHdr { g, shown, r, tw, reset, rename_id, layer_id } = *h; + let ctx = ui.ctx().clone(); + let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); + let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); + // Focus the field the frame it first exists: focusing an id not yet on screen leaves + // the accessibility tree with a focus it doesn't contain. + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { + crate::widgets::select_all(&ctx, response.id, &value); + response.request_focus(); + } + app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("select"))).unwrap_or(false) { + widgets::select_all(&ctx, response.id, &value); + } + // Escape cancels. egui drops the field's focus on Escape in the same frame, so the + // key is checked on the focus being lost as well as held. + let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); + let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + if escape || commit { + ctx.data_mut(|d| d.remove::(rename_id)); + // The field opens with the drawn name, so committing it unchanged is not a rename. + if !escape && !value.trim().is_empty() && value != shown { + actions.push(("prop.renameGroup".into(), json!({"layer": layer_id, "prop": g.uid, "name": value}))); + } + } else { + ctx.data_mut(|d| d.insert_temp(rename_id, value)); + } +} + +struct EditorCx<'a> { + bp: &'a egui::Painter, + layer: &'a Layer, + ectx: &'a EvalCtx<'a>, +} + +/// An effect's custom editor (curves, levels, roto...), or the histogram-only fallback. +fn effect_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &EditorCx, g: &PropGroup, er: Rect, actions: &mut Actions) { + let GroupKind::Effect { effect } = &g.kind else { return }; + match effect.as_str() { + "ec.color.curves" => curves_editor(app, ui, cx.bp, cx.layer, g, cx.ectx, er, actions), + effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::roto::ID => roto_editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, cx.bp, cx.layer, g, er, actions), + "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions), + crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => { + crate::panels::fx_editors::header_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions) + } + _ => histogram_only(app, ui, cx.bp, g, er), + } +} + +struct ReorderDrag<'a> { + tops: &'a [(u64, f32)], + y: f32, + du: u64, + drag_id: egui::Id, + layer_id: u64, +} + +/// Drag an effect header to reorder: the insertion line between headers, and the drop. +fn reorder_drag(ctx: &egui::Context, bp: &egui::Painter, body: Rect, t: &Tokens, st: &ReorderDrag, actions: &mut Actions) { + let ReorderDrag { tops, y, du, drag_id, layer_id } = *st; + if let Some(pos) = ctx.pointer_interact_pos() { + let slot = tops.iter().position(|(_, ty)| pos.y < *ty + (ROW + 2.0) / 2.0).unwrap_or(tops.len()); + let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0); + bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent)); + if ctx.input(|i| i.pointer.any_released()) { + if let Some(from) = tops.iter().position(|(u, _)| *u == du) { + let to = if slot > from { slot - 1 } else { slot }; + if to != from { + actions.push(("effect.reorder".into(), json!({"layer": layer_id, "effect": du, "index": to + 1}))); + } + } + ctx.data_mut(|d| d.remove::(drag_id)); + } + } + if !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(drag_id)); + } +} + +/// Drop effects and animation presets here. +fn drop_payload(ui: &egui::Ui, rect: Rect, p: &egui::Painter, t: &Tokens, layer_id: u64, actions: &mut Actions) { + let ctx = ui.ctx().clone(); + if let Some(payload) = egui::DragAndDrop::payload::(&ctx) + && ui.rect_contains_pointer(rect) + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer_id]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer_id)), + _ => None, + } + { + p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push((id.into(), params)); + egui::DragAndDrop::clear_payload(&ctx); + } + } +} + +fn about_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, bp: &egui::Painter, t: &Tokens, effect: &str, g: &PropGroup, r: Rect) { + // About…: the effect's name and category (AE's About dialog). + let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0)); + let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click()); + bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); + app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About"); + let apop = egui::Id::unique(("ec-about-pop", g.uid)); + if aresp.clicked() { + widgets::open_popup(ui, apop); + } + if let Some(spec) = effectcraft_engine::effects::find(effect) { + let lines = vec![ + crate::i18n::effects::name(spec.id, spec.name), + format!("Category: {}", crate::i18n::effects::category(spec.category)), + "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(), + ]; + let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); + } +} + /// The Effect Controls panel for the selected layer: a header with the comp and layer names, then /// one header per effect (Fx toggle, twirl, rename, Reset, About, label bar, reorder drag and its /// right-click menu) followed by that effect's custom editor and its property rows. Dragging @@ -99,32 +233,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rename_id = egui::Id::unique(("ec-rename", g.uid)); let editing: Option = ctx.data(|d| d.get_temp(rename_id)); let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0)); - if let Some(mut value) = editing.as_ref().cloned() { - let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); - let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); - // Focus the field the frame it first exists: focusing an id not yet on screen leaves - // the accessibility tree with a focus it doesn't contain. - if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { - crate::widgets::select_all(&ctx, response.id, &value); - response.request_focus(); - } - app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); - if ctx.data_mut(|d| d.remove_temp::(rename_id.with("select"))).unwrap_or(false) { - widgets::select_all(&ctx, response.id, &value); - } - // Escape cancels. egui drops the field's focus on Escape in the same frame, so the - // key is checked on the focus being lost as well as held. - let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); - let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); - if escape || commit { - ctx.data_mut(|d| d.remove::(rename_id)); - // The field opens with the drawn name, so committing it unchanged is not a rename. - if !escape && !value.trim().is_empty() && value != shown { - actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": g.uid, "name": value}))); - } - } else { - ctx.data_mut(|d| d.insert_temp(rename_id, value)); - } + let hdr_cx = EffectHdr { g, shown: &shown, r, tw, reset, rename_id, layer_id: layer.id.0 }; + if let Some(value) = editing.as_ref().cloned() { + rename_field(app, ui, &hdr_cx, value, &mut actions); } else { let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::semibold(12.0), t.text); app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); @@ -135,23 +246,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if rresp.clicked() { actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } - // About…: the effect's name and category (AE's About dialog). - let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0)); - let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click()); - bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); - app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About"); - let apop = egui::Id::unique(("ec-about-pop", g.uid)); - if aresp.clicked() { - widgets::open_popup(ui, apop); - } - if let Some(spec) = effectcraft_engine::effects::find(effect) { - let lines = vec![ - crate::i18n::effects::name(spec.id, spec.name), - format!("Category: {}", crate::i18n::effects::category(spec.category)), - "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(), - ]; - let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); - } + about_button(app, ui, &bp, &t, effect, g, r); let hresp = ui.interact( Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::unique(("ec-hdr", g.uid)), @@ -234,18 +329,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(er) { actions.push(("prop.select".into(), json!({"layer":layer.id.0,"prop":g.uid}))); } - match effect.as_str() { - "ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions), - effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions), - effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions), - effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions), - effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, &bp, &layer, g, er, &mut actions), - "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions), - crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => { - crate::panels::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions) - } - _ => histogram_only(app, ui, &bp, g, er), - } + effect_editor(app, ui, &EditorCx { bp: &bp, layer: &layer, ectx: &ectx }, g, er, &mut actions); } } let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() }; @@ -264,25 +348,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 4.0; } - // Drag an effect header to reorder: insertion line between headers. if let Some(du) = dragging { - if let Some(pos) = ctx.pointer_interact_pos() { - let slot = tops.iter().position(|(_, ty)| pos.y < *ty + (ROW + 2.0) / 2.0).unwrap_or(tops.len()); - let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0); - bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent)); - if ctx.input(|i| i.pointer.any_released()) { - if let Some(from) = tops.iter().position(|(u, _)| *u == du) { - let to = if slot > from { slot - 1 } else { slot }; - if to != from { - actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": du, "index": to + 1}))); - } - } - ctx.data_mut(|d| d.remove::(drag_id)); - } - } - if !ctx.input(|i| i.pointer.any_down()) { - ctx.data_mut(|d| d.remove::(drag_id)); - } + reorder_drag(&ctx, &bp, body, &t, &ReorderDrag { tops: &tops, y, du, drag_id, layer_id: layer.id.0 }, &mut actions); } // Right-click anywhere a control has no menu of its own: the Effect menu, which applies to // the selected layers (After Effects). Drawn after the rows, whose menus open first. @@ -297,21 +364,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects and animation presets here. - if let Some(payload) = egui::DragAndDrop::payload::(&ctx) - && ui.rect_contains_pointer(rect) - && let Some((id, params)) = match payload.as_ref() { - crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), - crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), - _ => None, - } - { - p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); - if ctx.input(|i| i.pointer.any_released()) { - actions.push((id.into(), params)); - egui::DragAndDrop::clear_payload(&ctx); - } - } + drop_payload(ui, rect, &p, &t, layer.id.0, &mut actions); if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") { app.ui.viewer.property_interacting = true; ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 2ea1f6e0b..05fea44c9 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -1,27 +1,27 @@ use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key}; -use super::*; +use super::{ + Align2, Color32, EffectcraftApp, EvalCtx, FxPick, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, icons, json, + pos2, vec2, widgets, +}; fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0); p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE)); } +/// The layer and whether the time is on a key, for the key navigator. +#[derive(Clone, Copy)] +struct KeyNav { + layer_id: u64, + on_key: bool, +} + /// ◀ ◆ ▶ at the right end of an animated parameter's row. -fn key_navigator( - app: &mut EffectcraftApp, - ui: &mut egui::Ui, - p: &egui::Painter, - layer: &Layer, - prop: &Property, - ectx: &EvalCtx, - r: Rect, - actions: &mut Actions, -) { +fn key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, nav: KeyNav, prop: &Property, r: Rect, actions: &mut Actions) { + let KeyNav { layer_id, on_key } = nav; let t = app.tokens; let cy = r.center().y; let uid = prop.uid; - let half = ectx.comp.frame_duration().0 / 2; - let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half); for (what, dx) in [("prev", -44.0), ("key", -32.0), ("next", -20.0)] { let cr = Rect::from_center_size(pos2(r.max.x + dx, cy), vec2(12.0, 16.0)); let resp = ui.interact(cr, egui::Id::unique(("ec-nav", uid, what)), Sense::click()); @@ -48,7 +48,7 @@ fn key_navigator( match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer_id, "prop": uid, "selected": true}))), } } } @@ -71,6 +71,44 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p } } +/// What the value controls of one property row share. +#[derive(Clone, Copy)] +struct PropCx<'a> { + p: &'a egui::Painter, + layer: &'a Layer, + group: &'a PropGroup, + prop: &'a Property, + r: Rect, + vx: f32, + cy: f32, + swr: Rect, + t: Tokens, + uid: u64, + scrub: bool, +} + +impl PropCx<'_> { + fn set(&self, actions: &mut Actions, v: serde_json::Value) { + let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + } + + fn twirl_open(&self, app: &mut EffectcraftApp, ui: &mut egui::Ui) { + let PropCx { swr, cy, uid, prop, t, .. } = self; + let (cy, uid) = (*cy, *uid); + let open = app.ui.fx_slider_open.contains(&uid); + let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); + if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), t).clicked() { + if open { + app.ui.fx_slider_open.remove(&uid); + } else { + app.ui.fx_slider_open.insert(uid); + } + } + app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); + } +} + /// One property row: stopwatch, name, value control (and the keyframe navigator). Returns the /// value fields of a vector, one per dimension, for the pick whip. #[allow(clippy::too_many_arguments)] @@ -115,7 +153,9 @@ fn prop_row( } } if prop.is_animated() { - key_navigator(app, ui, p, layer, prop, ectx, r, actions); + let half = ectx.comp.frame_duration().0 / 2; + let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half); + key_navigator(app, ui, p, KeyNav { layer_id: layer.id.0, on_key }, prop, r, actions); } let value = ectx.value(layer, prop); // Keep a shared value column while names and their icons follow the group hierarchy. @@ -150,260 +190,320 @@ fn prop_row( app.show_panel(crate::dock::PanelKind::Timeline); } app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); - let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); - let set = |actions: &mut Actions, v: serde_json::Value| { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) - }; - let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { - let open = app.ui.fx_slider_open.contains(&uid); - let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); - if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), &t).clicked() { - if open { - app.ui.fx_slider_open.remove(&uid); - } else { - app.ui.fx_slider_open.insert(uid); - } - } - app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); - }; + let cx = PropCx { p, layer, group, prop, r, vx, cy, swr, t, uid, scrub }; match &value { - Value::Scalar(v) if matches!(prop.ui, ParamUi::Mask) => { - // A mask of this layer ("Path" popup). - let mut opts = vec!["None".to_string()]; - if let Some(m) = layer.masks() { - opts.extend(m.groups().map(|g| g.name.clone())); - } - let i = v.round().max(0.0) as usize; - let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}")); - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); - let pop = egui::Id::unique(("ec-mpop", uid)); - if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) { - set(actions, json!(ni as f64)); - } - } - Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => { - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); - app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); - app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); - if let Some(nv) = nv { - set(actions, json!(nv)); - } - twirl_open(app, ui); - } - Value::Scalar(v) => { - let (min, max, dec) = match &prop.ui { - ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), - _ => (-1e9, 1e9, 1), - }; - let suffix = match prop.ui { - ParamUi::Percent => "%", - _ => "", - }; - let speed = widgets::slider_scrub_speed(&prop.ui); - let (vr, nv, _) = widgets::hot_number_at( - ui, - pos2(vx, cy - 9.0), - egui::Id::unique(("ec-v", uid)), - *v, - speed, - (min, max), - dec.max(if dec == 0 { 0 } else { 1 }), - suffix, - &t, - ); - app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); - if let Some(nv) = nv { - set(actions, json!(nv)); - } - // AE hides a slider param's slider until its twirl is opened (see `slider_row`). - if matches!(prop.ui, ParamUi::Slider { .. }) { - twirl_open(app, ui); - } - } - Value::Vec2(_) | Value::Vec3(_) => { - let c = value.components(); - let n = shown_dims(layer, prop, &value); - let mut x = vx; - if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { - let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); - let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point"); - if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t) - .on_hover_text(crate::i18n::tr("Click, then click in the Composition panel")) - .clicked() - { - toggle_pick(app, "point", layer, prop); - } - app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name); - x += 22.0; - } - for d in 0..n.min(c.len()) { - let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t); - app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); - dims.push(vr); - if let Some(nv) = nv { - let mut nc = c.clone(); - nc[d] = nv; - set(actions, json!(nc)); - // Typed into this component only: other selected layers keep the rest of - // their own value (#1434). - if let Some((_, a)) = actions.last_mut() { - a["dims"] = json!([d]); - } - } - x = vr.max.x + 8.0; - } - } - Value::Color(c) => { - let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0)); - let pop = egui::Id::unique(("ec-cpop", uid)); - if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); - let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0)); - let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); - if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) - .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) - .clicked() - { - toggle_pick(app, "color", layer, prop); - } - app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); - let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { - set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); - } - } - Value::Bool(b) => { - let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() { - set(actions, json!(!b)); - } - p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); - app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name); - } - Value::Enum(i) => { - if let ParamUi::Popup { options } = &prop.ui { - let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max); - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0)); - let pop = egui::Id::unique(("ec-epop", uid)); - if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) { - set(actions, json!(ni)); - } - } - } - Value::Layer(l) => { - let comp = app.session.active_comp_arc(); - if let Some(comp) = comp { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); - if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) { - set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - // Source / Masks / Effects & Masks (the hidden companion parameter). - if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { - let si = src.value.as_enum() as usize; - let opts: Vec = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect(); - let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0)); - let spop = egui::Id::unique(("ec-lspop", uid)); - if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() { - widgets::open_popup(ui, spop); - } - app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name); - if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni}))); - } - } - } - } + Value::Scalar(_) if matches!(prop.ui, ParamUi::Mask) => value_mask(app, ui, &cx, &value, actions), + Value::Scalar(_) if matches!(prop.ui, ParamUi::Angle) => value_angle(app, ui, &cx, &value, actions), + Value::Scalar(_) => value_scalar(app, ui, &cx, &value, actions), + Value::Vec2(_) | Value::Vec3(_) => dims = value_vector(app, ui, &cx, &value, actions), + Value::Color(_) => value_color(app, ui, &cx, &value, actions), + Value::Bool(_) => value_toggle(app, ui, &cx, &value, actions), + Value::Enum(_) => value_popup(app, ui, &cx, &value, actions), + Value::Layer(_) => value_layer_ref(app, ui, &cx, &value, actions), // EXtractoR's red / green / blue / alpha: a popup of the footage's OpenEXR channels (#295). - Value::Str(s) if crate::panels::fx_editors::is_extractor_channel(group, prop) => { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); - crate::panels::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); - } + Value::Str(_) if crate::panels::fx_editors::is_extractor_channel(group, prop) => value_extractor(app, ui, &cx, &value, actions), // A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…, // as in After Effects. - Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => { - let bw = 70.0; - let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); - if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() { - let filters: Vec<&str> = match &prop.ui { - ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), - _ => vec![], - }; - match app.hooks.pick_files.as_ref() { - Some(pick) => { - if let Some(path) = pick(&filters).into_iter().next() { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); - } - } - None => app.ui.status = "no file dialog available: set the path with prop.set".into(), - } - } - app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); - let shown = if s.trim().is_empty() { - crate::i18n::tr("None").to_string() - } else if s.contains('\n') { - prop.name.clone() - } else { - effectcraft_engine::sequence::file_name(s) - }; - let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); - let np = p.with_clip_rect(nr.intersect(p.clip_rect())); - np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); - let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover()); - if !s.trim().is_empty() && !s.contains('\n') { - hover.on_hover_text(s.as_str()); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); - } + Value::Str(_) if matches!(prop.ui, ParamUi::File { .. }) => value_file(app, ui, &cx, &value, actions), // A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names): // typed in a field, set when it loses the focus (Esc keeps the old text). - Value::Str(s) if matches!(prop.ui, ParamUi::Text) => { - let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); - let bid = egui::Id::unique(("ec-text", uid)); - let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone()); - let resp = widgets::text_field(ui, fr, &mut buf, "", &t); - // The field has no visible hint: give the screen reader the parameter's name. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); - app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); - if resp.lost_focus() { - if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); - } - ui.data_mut(|d| d.remove::(bid)); - } else if resp.has_focus() { - ui.data_mut(|d| d.insert_temp(bid, buf)); - } - } - Value::Gradient(g) => { - let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); - let n = 48; - for k in 0..n { - let f = k as f64 / (n - 1) as f64; - let c = gradient_at(g, f); - let x0 = gr.min.x + gr.width() * k as f32 / n as f32; - let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32; - p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c); - } - p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name); - } + Value::Str(_) if matches!(prop.ui, ParamUi::Text) => value_text(app, ui, &cx, &value, actions), + Value::Gradient(_) => value_gradient(app, &cx, &value), _ => {} } dims } +fn value_mask(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; + // A mask of this layer ("Path" popup). + let mut opts = vec!["None".to_string()]; + if let Some(m) = layer.masks() { + opts.extend(m.groups().map(|g| g.name.clone())); + } + let i = v.round().max(0.0) as usize; + let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}")); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + let pop = egui::Id::unique(("ec-mpop", uid)); + if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) { + cx.set(actions, json!(ni as f64)); + } +} + +fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); + app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); + app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); + if let Some(nv) = nv { + cx.set(actions, json!(nv)); + } + cx.twirl_open(app, ui); +} + +fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let (min, max, dec) = match &prop.ui { + ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), + _ => (-1e9, 1e9, 1), + }; + let suffix = match prop.ui { + ParamUi::Percent => "%", + _ => "", + }; + let speed = widgets::slider_scrub_speed(&prop.ui); + let (vr, nv, _) = widgets::hot_number_at( + ui, + pos2(vx, cy - 9.0), + egui::Id::unique(("ec-v", uid)), + *v, + &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(if dec == 0 { 0 } else { 1 }), suffix }, + &t, + ); + app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); + if let Some(nv) = nv { + cx.set(actions, json!(nv)); + } + // AE hides a slider param's slider until its twirl is opened (see `slider_row`). + if matches!(prop.ui, ParamUi::Slider { .. }) { + cx.twirl_open(app, ui); + } +} + +fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) -> Vec { + let mut dims = vec![]; + let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; + let c = value.components(); + let n = shown_dims(layer, prop, value); + let mut x = vx; + if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { + let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point"); + if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t) + .on_hover_text(crate::i18n::tr("Click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, "point", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name); + x += 22.0; + } + for d in 0..n.min(c.len()) { + let (vr, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("ec-v", uid, d)), + c[d], + &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" }, + &t, + ); + app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); + dims.push(vr); + if let Some(nv) = nv { + let mut nc = c.clone(); + nc[d] = nv; + cx.set(actions, json!(nc)); + // Typed into this component only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!([d]); + } + } + x = vr.max.x + 8.0; + } + dims +} + +fn value_color(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Color(c) = value else { + return; + }; + let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; + let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0)); + let pop = egui::Id::unique(("ec-cpop", uid)); + if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); + let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); + if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) + .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, "color", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); + let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + cx.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); + } +} + +fn value_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Bool(b) = value else { + return; + }; + let PropCx { t, uid, cy, vx, p, prop, .. } = *cx; + let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); + if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() { + cx.set(actions, json!(!b)); + } + p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); + app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name); +} + +fn value_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Enum(i) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx; + if let ParamUi::Popup { options } = &prop.ui { + let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0)); + let pop = egui::Id::unique(("ec-epop", uid)); + if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) { + cx.set(actions, json!(ni)); + } + } +} + +fn value_layer_ref(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Layer(l) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, group, .. } = *cx; + let comp = app.session.active_comp_arc(); + if let Some(comp) = comp { + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); + if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) { + cx.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + // Source / Masks / Effects & Masks (the hidden companion parameter). + if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { + let si = src.value.as_enum() as usize; + let opts: Vec = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect(); + let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0)); + let spop = egui::Id::unique(("ec-lspop", uid)); + if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() { + widgets::open_popup(ui, spop); + } + app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name); + if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni}))); + } + } + } +} + +fn value_extractor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { cy, vx, r, layer, prop, .. } = *cx; + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + crate::panels::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); +} + +fn value_file(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, layer, prop, .. } = *cx; + let bw = 70.0; + let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); + if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() { + let filters: Vec<&str> = match &prop.ui { + ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), + _ => vec![], + }; + match app.hooks.pick_files.as_ref() { + Some(pick) => { + if let Some(path) = pick(&filters).into_iter().next() { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); + } + } + None => app.ui.status = "no file dialog available: set the path with prop.set".into(), + } + } + app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); + let shown = if s.trim().is_empty() { + crate::i18n::tr("None").to_string() + } else if s.contains('\n') { + prop.name.clone() + } else { + effectcraft_engine::sequence::file_name(s) + }; + let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + let np = p.with_clip_rect(nr.intersect(p.clip_rect())); + np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); + let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover()); + if !s.trim().is_empty() && !s.contains('\n') { + hover.on_hover_text(s); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); +} + +fn value_text(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let bid = egui::Id::unique(("ec-text", uid)); + let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.to_owned()); + let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + // The field has no visible hint: give the screen reader the parameter's name. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); + app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); + if resp.lost_focus() { + if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); + } + ui.data_mut(|d| d.remove::(bid)); + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(bid, buf)); + } +} + +fn value_gradient(app: &mut EffectcraftApp, cx: &PropCx, value: &Value) { + let Value::Gradient(g) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx; + let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); + let n = 48; + for k in 0..n { + let f = k as f64 / (n - 1) as f64; + let c = gradient_at(g, f); + let x0 = gr.min.x + gr.width() * k as f32 / n as f32; + let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c); + } + p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name); +} + /// How many of a vector parameter's values show (a 3D point's Z only on a 3D layer). fn shown_dims(layer: &Layer, prop: &Property, value: &Value) -> usize { let n = value.components().len(); diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index e71632180..b11b63a35 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -1,6 +1,10 @@ +use std::ops::ControlFlow; + use super::common::{Actions, effect_active, gesture_key}; use super::rows::FxScope; -use super::*; +use super::{ + Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Layer, Mat3, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, fw, json, vec2, +}; // --------------------------------------------------------------------------------------------- // Viewer hook @@ -22,6 +26,156 @@ fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx } } +/// An armed crosshair / eyedropper pick: the hint chip, the cursor preview, and the click that +/// sets or samples. `Break` when the picked layer is gone; `Continue` carries the point preview. +fn fx_pick_session( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &crate::panels::viewer::ViewerMap, + ectx: &EvalCtx, + l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3, + actions: &mut Actions, +) -> ControlFlow<(), Option> { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut point_preview = None; + let Some(pick) = app.ui.fx_pick.clone() else { + return ControlFlow::Continue(None); + }; + let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { + app.ui.fx_pick = None; + return ControlFlow::Break(()); + }; + // Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`. + let keyer = (pick.kind == "color") + .then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some())) + .flatten() + .filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying")) + .map(|g| g.uid); + if pick.kind == "color" && keyer.is_none() { + // GPU frames: read the shown frame back for sampling. + app.viewer_pixels(); + } + let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click()); + app.auto.add("viewer.fxPick", map.area, &pick.name); + let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) }; + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text); + if let Some(hp) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(hp); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() { + point_preview = Some(hp); + } + if pick.kind == "color" + && keyer.is_none() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + // No frame to sample yet (it is still rendering, or its readback is pending): the click + // leaves the eyedropper armed instead of using it up without a colour. + let unsampled = pick.kind == "color" && keyer.is_none() && app.viewer_image.is_none(); + if resp.clicked() + && !unsampled + && let Some(pos) = resp.interact_pointer_pos() + { + let c = map.to_comp(pos); + // Effect points and the keyer's input are in effect space. + let m = crate::panels::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer)); + if pick.kind == "point" { + if let Some(lp) = fw::comp_to_layer(&m, c) { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); + } + } else if let Some(fx) = keyer { + // Ctrl/Cmd+click averages the 5 × 5 pixels around the point. + if let Some(lp) = fw::comp_to_layer(&m, c) { + let average = ui.input(|i| i.modifiers.command); + actions.push(( + "effect.pickColor".into(), + json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}), + )); + } + } else if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]}))); + } + app.ui.fx_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.fx_pick = None; + app.ui.status.clear(); + } + + ControlFlow::Continue(point_preview) +} + +/// An armed text-colour pick: sample the shown frame on click, Esc cancels. +fn text_color_pick( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &crate::panels::viewer::ViewerMap, + ectx: &EvalCtx, + actions: &mut Actions, +) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let Some(pick) = app.ui.text_color_pick.clone() else { + return; + }; + if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else { + app.viewer_pixels(); + let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click()); + app.auto.add("viewer.textColorPick", map.area, &pick.field); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); + if let Some(pos) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(pos); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + { + let mut params = json!({"layer": pick.layer}); + params[&pick.field] = json!([s[0], s[1], s[2]]); + if let Some(range) = pick.range { + params["range"] = json!(range); + } + actions.push(("layer.setText".into(), params)); + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + } +} + /// Effect Controls' part of the Composition viewer, called by the viewer after its own /// interaction (so these controls sit on top): /// @@ -42,124 +196,11 @@ pub fn viewer_hook( let t = app.tokens; let ctx = ui.ctx().clone(); let mut actions: Actions = vec![]; - let mut point_preview = None; - if let Some(pick) = app.ui.fx_pick.clone() { - let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { - app.ui.fx_pick = None; - return; - }; - // Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`. - let keyer = (pick.kind == "color") - .then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some())) - .flatten() - .filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying")) - .map(|g| g.uid); - if pick.kind == "color" && keyer.is_none() { - // GPU frames: read the shown frame back for sampling. - app.viewer_pixels(); - } - let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click()); - app.auto.add("viewer.fxPick", map.area, &pick.name); - let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) }; - let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); - painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); - painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text); - if let Some(hp) = resp.hover_pos() { - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - let c = map.to_comp(hp); - app.pointer_comp = Some([c[0] as f32, c[1] as f32]); - if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() { - point_preview = Some(hp); - } - if pick.kind == "color" - && keyer.is_none() - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); - painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); - painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); - } - } - // No frame to sample yet (it is still rendering, or its readback is pending): the click - // leaves the eyedropper armed instead of using it up without a colour. - let unsampled = pick.kind == "color" && keyer.is_none() && app.viewer_image.is_none(); - if resp.clicked() - && !unsampled - && let Some(pos) = resp.interact_pointer_pos() - { - let c = map.to_comp(pos); - // Effect points and the keyer's input are in effect space. - let m = crate::panels::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer)); - if pick.kind == "point" { - if let Some(lp) = fw::comp_to_layer(&m, c) { - actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); - } - } else if let Some(fx) = keyer { - // Ctrl/Cmd+click averages the 5 × 5 pixels around the point. - if let Some(lp) = fw::comp_to_layer(&m, c) { - let average = ui.input(|i| i.modifiers.command); - actions.push(( - "effect.pickColor".into(), - json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}), - )); - } - } else if let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]}))); - } - app.ui.fx_pick = None; - app.ui.status.clear(); - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.fx_pick = None; - app.ui.status.clear(); - } - } - if let Some(pick) = app.ui.text_color_pick.clone() { - if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { - app.ui.text_color_pick = None; - app.ui.status.clear(); - } else { - app.viewer_pixels(); - let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click()); - app.auto.add("viewer.textColorPick", map.area, &pick.field); - let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); - painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); - painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); - if let Some(pos) = resp.hover_pos() { - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - let c = map.to_comp(pos); - app.pointer_comp = Some([c[0] as f32, c[1] as f32]); - if let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); - painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); - painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); - } - } - if resp.clicked() - && let Some(pos) = resp.interact_pointer_pos() - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) - { - let mut params = json!({"layer": pick.layer}); - params[&pick.field] = json!([s[0], s[1], s[2]]); - if let Some(range) = pick.range { - params["range"] = json!(range); - } - actions.push(("layer.setText".into(), params)); - app.ui.text_color_pick = None; - app.ui.status.clear(); - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.text_color_pick = None; - app.ui.status.clear(); - } - } - } + let point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { + ControlFlow::Break(()) => return, + ControlFlow::Continue(p) => p, + }; + text_color_pick(app, ui, painter, map, ectx, &mut actions); let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some(); let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); if app.ui.viewer.show_layer_controls || point_pick.is_some() { diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index fa7d2ac40..380d1d869 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -264,32 +264,13 @@ fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec
 bool {
     // Contents menu.
     let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
     let mpop = egui::Id::unique("fx-contents-menu");
-    if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
+    if widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
         widgets::open_popup(ui, mpop);
     }
     app.auto.add("effects.menu", mr, "Effects & Presets options");
@@ -339,7 +320,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let v = &mut app.ui.effects_view;
         match i {
             i if menu.get(i) == Some(©_label) => {
-                if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
+                if let Err(e) = crate::menus::invoke(app, ctx, "effects.copyPresetsFolderPath", json!({})) {
                     app.ui.status = e;
                 }
             }
@@ -354,6 +335,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             _ => {}
         }
     }
+    force_scan
+}
+
+/// The Effects & Presets panel: search, a contents menu (colour-depth and GPU filters, whether
+/// presets show, categories or one alphabetical list, refresh and clear of the recently used
+/// list), then Favourites, Recently Used, Animation Presets and the effects by category or
+/// alphabetically, filtered by the query. Picking an effect or preset applies it to the selection
+/// (`effect.apply` or the preset's own command); applying an effect also records it as recently
+/// used and opens Effect Controls. Without a selected layer it only reports that in
+/// `app.ui.status`.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let p = ui.painter().clone();
+    // Track the last applied effect (menus, drag and drop, the control channel) as recently used.
+    if let Some(last) = app.session.state.last_effect.clone()
+        && app.ui.effects_recent.first() != Some(&last)
+    {
+        push_recent(&mut app.ui.effects_recent, &last);
+    }
+    let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
+    let mut q = app.ui.effects_search.clone();
+    widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
+    app.ui.effects_search = q.clone();
+    app.auto.add("effects.search", sr, "Search");
+    let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t);
     let view = app.ui.effects_view.clone();
     let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
     let lp = p.with_clip_rect(list);
diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs
index f342d5636..157288b94 100644
--- a/crates/ui-egui/src/panels/expr_editor.rs
+++ b/crates/ui-egui/src/panels/expr_editor.rs
@@ -331,8 +331,16 @@ fn layout(text: &str, font: FontId, base: Color32, sx: &Syntax, p: &Scripting, b
     job
 }
 
+/// The scripting preferences, text colour and row count an [`editor`] is drawn with.
+pub struct EditorOpts<'a> {
+    pub prefs: &'a Scripting,
+    pub color: Color32,
+    pub rows: usize,
+}
+
 /// The editor: returns the response; `buf` is edited in place.
-pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: &Scripting, color: Color32, rows: usize, t: &Tokens) -> egui::Response {
+pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, opts: EditorOpts, t: &Tokens) -> egui::Response {
+    let EditorOpts { prefs: p, color, rows } = opts;
     let size = (p.editor_font_size.clamp(8, 40) as f32) * 0.9;
     let font = Tokens::mono(size);
     // Line numbers in a gutter on the left.
diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs
index 67128d04d..e121d5d02 100644
--- a/crates/ui-egui/src/panels/forms.rs
+++ b/crates/ui-egui/src/panels/forms.rs
@@ -206,6 +206,280 @@ fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option (String, Vec) {
+    let names: Vec<(String, Value)> =
+        s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
+    let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
+    let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
+    (
+        "New Composition from Selection".into(),
+        vec![
+            Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
+            Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
+            Field::num("duration", "Still Duration (seconds)", still),
+            Field::bool("addToRenderQueue", "Add to Render Queue", false),
+            Field::bool("sequence", "Sequence Layers", false),
+            Field::bool("overlap", "Overlap", false),
+            Field::num("overlapDuration", "Duration (seconds)", 1.0),
+            Field::choice(
+                "transition",
+                "Transition",
+                &[("Off", json!("off")), ("Dissolve Front Layer", json!("dissolveFront")), ("Cross Dissolve Front and Back Layers", json!("crossDissolve"))],
+                0,
+            ),
+        ],
+    )
+}
+
+/// The Interpret Footage dialog for the selected footage (or its proxy), if there is one.
+fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> {
+    let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
+        (_, Some(px)) if proxy => Some(px.footage.clone()),
+        (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
+        _ => None,
+    });
+    let f = f?;
+    let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
+    let mut fields = vec![
+        // Main Options ▸ Alpha.
+        Field::choice(
+            "alpha",
+            "Alpha",
+            &[
+                ("Interpret Straight - Unmatted", json!("straight")),
+                ("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
+                ("Ignore", json!("ignore")),
+                ("Guess", json!("guess")),
+            ],
+            f.alpha as usize,
+        ),
+        Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
+        Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
+        // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
+        Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
+        Field::choice(
+            "fields",
+            "Separate Fields",
+            &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
+            f.fields as usize,
+        ),
+        Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
+        Field::num("loop", "Loop (times)", f.loop_count as f64),
+        // Color.
+        Field::choice(
+            "colorProfile",
+            "Assign Profile",
+            &[
+                ("Embedded / sRGB", json!("auto")),
+                ("sRGB IEC61966-2.1", json!("srgb")),
+                ("HDTV (Rec. 709)", json!("rec709")),
+                ("Rec. 2020", json!("rec2020")),
+                ("Display P3", json!("p3")),
+                ("ACEScg", json!("acescg")),
+                ("ACES2065-1", json!("aces2065")),
+                ("Rec. 2100 PQ", json!("rec2100pq")),
+                ("Rec. 2100 HLG", json!("rec2100hlg")),
+            ],
+            f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
+        ),
+        Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
+        Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
+    ];
+    // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
+    // After Effects), then Start Timecode.
+    let mut timing = Vec::new();
+    if f.kind == effectcraft_engine::project::FootageKind::Sequence {
+        let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
+            _ if f.alphabetical => "files in alphabetical order".into(),
+            Some(gaps) => format!("{gaps} shown as color bars"),
+            None => "no missing frames".into(),
+        };
+        timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
+    }
+    if f.kind != effectcraft_engine::project::FootageKind::Still {
+        timing.push(Field::choice(
+            "overrideStart",
+            "Start Timecode",
+            &[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
+            usize::from(f.start_timecode.is_some()),
+        ));
+        timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
+    }
+    let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
+    fields.splice(at..at, timing);
+    Some((if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields))
+}
+
+/// The Project Settings dialog's title and fields.
+fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec) {
+    let st = &s.project.settings;
+    // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
+    // tr_args: wrapping the assembled string in tr() would look up the backend's name.
+    let gpu_label = match &s.accel {
+        Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
+        None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
+    };
+    let depth = match st.bit_depth.label() {
+        l if l.starts_with("16") => 1,
+        l if l.starts_with("32") => 2,
+        _ => 0,
+    };
+    (
+        "Project Settings".into(),
+        vec![
+            Field::choice(
+                "bitDepth",
+                "Depth",
+                &[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
+                depth,
+            ),
+            Field::choice(
+                "timeDisplay",
+                "Time display style",
+                &[
+                    ("Timecode", json!("timecode")),
+                    ("Frames", json!("frames")),
+                    ("Feet + Frames (35mm)", json!("feet35")),
+                    ("Feet + Frames (16mm)", json!("feet16")),
+                ],
+                {
+                    use effectcraft_engine::project::TimeDisplayStyle as T;
+                    match st.time_display {
+                        T::Timecode => 0,
+                        T::Frames => 1,
+                        T::Feet35 => 2,
+                        T::Feet16 => 3,
+                    }
+                },
+            ),
+            // Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
+            Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
+            Field::choice(
+                "colorEngine",
+                "Color engine",
+                &[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
+                usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
+            ),
+            Field::choice(
+                "workingSpace",
+                "Working space",
+                &[
+                    ("None", json!("none")),
+                    ("sRGB IEC61966-2.1", json!("srgb")),
+                    ("HDTV (Rec. 709)", json!("rec709")),
+                    ("Rec. 2020", json!("rec2020")),
+                    ("Display P3", json!("p3")),
+                    ("ACEScg", json!("acescg")),
+                    ("ACES2065-1", json!("aces2065")),
+                ],
+                st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
+            ),
+            // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
+            Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
+            Field::bool("linearize", "Linearize working space", st.linearize),
+            Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
+            Field::choice(
+                "hdr",
+                "HDR on SDR displays and outputs",
+                &[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
+                st.hdr as usize,
+            ),
+            Field::choice(
+                "outputSpace",
+                "Output color space",
+                &[
+                    ("sRGB IEC61966-2.1 (default)", json!("default")),
+                    ("HDTV (Rec. 709)", json!("rec709")),
+                    ("Rec. 2020", json!("rec2020")),
+                    ("Display P3", json!("p3")),
+                    ("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
+                    ("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
+                ],
+                {
+                    use effectcraft_engine::project::ColorSpace as C;
+                    match st.output_space {
+                        Some(C::Rec709) => 1,
+                        Some(C::Rec2020) => 2,
+                        Some(C::DisplayP3) => 3,
+                        Some(C::Rec2100Pq) => 4,
+                        Some(C::Rec2100Hlg) => 5,
+                        _ => 0,
+                    }
+                },
+            ),
+            // Video Rendering and Effects ▸ Use.
+            Field::choice(
+                "renderer",
+                "Video rendering and effects",
+                &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
+                usize::from(!st.gpu_acceleration),
+            ),
+        ],
+    )
+}
+
+/// The Export Adobe Premiere Pro Project dialog's title and fields.
+fn export_timeline_form(s: &effectcraft_engine::Session) -> (String, Vec) {
+    let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
+    let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
+    let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
+    (
+        "Export Adobe Premiere Pro Project".into(),
+        vec![
+            Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
+            Field::save_path("path", "File", &path),
+            Field::choice(
+                "format",
+                "Format",
+                &[
+                    ("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
+                    ("FCPXML (.fcpxml)", json!("fcpxml")),
+                    ("OpenTimelineIO (.otio)", json!("otio")),
+                    ("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
+                    ("AAF (.aaf)", json!("aaf")),
+                    ("OMF (.omf, audio)", json!("omf")),
+                ],
+                0,
+            ),
+            Field::choice(
+                "prerender",
+                "Pre-render (ProRes 4444)",
+                &[
+                    ("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
+                    ("All layers", json!("all")),
+                    ("None (leave them out)", json!("none")),
+                ],
+                0,
+            ),
+            Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
+        ],
+    )
+}
+
+/// The Layer ▸ Transform value dialog for the property named by `p` (default Position).
+fn transform_form(p: &Value, layer: Option<&effectcraft_engine::project::Layer>, lt: effectcraft_engine::time::Tick) -> (String, Vec) {
+    let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
+    let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
+    let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
+    let three = layer.is_some_and(|l| l.is_3d());
+    let title = match prop {
+        "anchor" => "Anchor Point",
+        "position" => "Position",
+        "scale" => "Scale",
+        "orientation" => "Orientation",
+        "rotation" => "Rotation",
+        _ => "Opacity",
+    };
+    let fields = match prop {
+        "rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
+        "orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
+        _ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
+        _ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
+    };
+    (title.into(), fields)
+}
+
 /// If `id` is a dialog command invoked without its parameters, open its form and return true.
 pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
     let s = &app.session;
@@ -281,27 +555,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false };
             (pr.name.clone(), fields)
         }
-        "layer.setTransform" if !has(p, &["value"]) => {
-            let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
-            let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
-            let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
-            let three = layer.is_some_and(|l| l.is_3d());
-            let title = match prop {
-                "anchor" => "Anchor Point",
-                "position" => "Position",
-                "scale" => "Scale",
-                "orientation" => "Orientation",
-                "rotation" => "Rotation",
-                _ => "Opacity",
-            };
-            let fields = match prop {
-                "rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
-                "orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
-                _ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
-                _ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
-            };
-            (title.into(), fields)
-        }
+        "layer.setTransform" if !has(p, &["value"]) => transform_form(p, layer, lt),
         "path.freeTransform" if !has(p, &["scale", "rotation", "offset"]) => (
             "Free Transform Points".into(),
             vec![
@@ -377,32 +631,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
         "file.newCompFromSelection"
             if s.state.project_selection.len() > 1 && !has(p, &["single", "dimensionsFrom", "duration", "sequence", "addToRenderQueue"]) =>
         {
-            let names: Vec<(String, Value)> =
-                s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
-            let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
-            let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
-            (
-                "New Composition from Selection".into(),
-                vec![
-                    Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
-                    Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
-                    Field::num("duration", "Still Duration (seconds)", still),
-                    Field::bool("addToRenderQueue", "Add to Render Queue", false),
-                    Field::bool("sequence", "Sequence Layers", false),
-                    Field::bool("overlap", "Overlap", false),
-                    Field::num("overlapDuration", "Duration (seconds)", 1.0),
-                    Field::choice(
-                        "transition",
-                        "Transition",
-                        &[
-                            ("Off", json!("off")),
-                            ("Dissolve Front Layer", json!("dissolveFront")),
-                            ("Cross Dissolve Front and Back Layers", json!("crossDissolve")),
-                        ],
-                        0,
-                    ),
-                ],
-            )
+            new_comp_from_selection_form(s)
         }
         "layer.sequence" if !has(p, &["overlap"]) => (
             "Sequence Layers".into(),
@@ -425,188 +654,10 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) =>
         {
             let proxy = id == "file.interpretProxy";
-            let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
-                (_, Some(px)) if proxy => Some(px.footage.clone()),
-                (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
-                _ => None,
-            });
-            let Some(f) = f else { return false };
-            let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
-            let mut fields = vec![
-                // Main Options ▸ Alpha.
-                Field::choice(
-                    "alpha",
-                    "Alpha",
-                    &[
-                        ("Interpret Straight - Unmatted", json!("straight")),
-                        ("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
-                        ("Ignore", json!("ignore")),
-                        ("Guess", json!("guess")),
-                    ],
-                    f.alpha as usize,
-                ),
-                Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
-                Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
-                // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
-                Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
-                Field::choice(
-                    "fields",
-                    "Separate Fields",
-                    &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
-                    f.fields as usize,
-                ),
-                Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
-                Field::num("loop", "Loop (times)", f.loop_count as f64),
-                // Color.
-                Field::choice(
-                    "colorProfile",
-                    "Assign Profile",
-                    &[
-                        ("Embedded / sRGB", json!("auto")),
-                        ("sRGB IEC61966-2.1", json!("srgb")),
-                        ("HDTV (Rec. 709)", json!("rec709")),
-                        ("Rec. 2020", json!("rec2020")),
-                        ("Display P3", json!("p3")),
-                        ("ACEScg", json!("acescg")),
-                        ("ACES2065-1", json!("aces2065")),
-                        ("Rec. 2100 PQ", json!("rec2100pq")),
-                        ("Rec. 2100 HLG", json!("rec2100hlg")),
-                    ],
-                    f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
-                ),
-                Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
-                Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
-            ];
-            // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
-            // After Effects), then Start Timecode.
-            let mut timing = Vec::new();
-            if f.kind == effectcraft_engine::project::FootageKind::Sequence {
-                let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
-                    _ if f.alphabetical => "files in alphabetical order".into(),
-                    Some(gaps) => format!("{gaps} shown as color bars"),
-                    None => "no missing frames".into(),
-                };
-                timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
-            }
-            if f.kind != effectcraft_engine::project::FootageKind::Still {
-                timing.push(Field::choice(
-                    "overrideStart",
-                    "Start Timecode",
-                    &[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
-                    usize::from(f.start_timecode.is_some()),
-                ));
-                timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
-            }
-            let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
-            fields.splice(at..at, timing);
-            (if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields)
-        }
-        "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
-            let st = &s.project.settings;
-            // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
-            // tr_args: wrapping the assembled string in tr() would look up the backend's name.
-            let gpu_label = match &s.accel {
-                Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
-                None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
-            };
-            let depth = match st.bit_depth.label() {
-                l if l.starts_with("16") => 1,
-                l if l.starts_with("32") => 2,
-                _ => 0,
-            };
-            (
-                "Project Settings".into(),
-                vec![
-                    Field::choice(
-                        "bitDepth",
-                        "Depth",
-                        &[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
-                        depth,
-                    ),
-                    Field::choice(
-                        "timeDisplay",
-                        "Time display style",
-                        &[
-                            ("Timecode", json!("timecode")),
-                            ("Frames", json!("frames")),
-                            ("Feet + Frames (35mm)", json!("feet35")),
-                            ("Feet + Frames (16mm)", json!("feet16")),
-                        ],
-                        {
-                            use effectcraft_engine::project::TimeDisplayStyle as T;
-                            match st.time_display {
-                                T::Timecode => 0,
-                                T::Frames => 1,
-                                T::Feet35 => 2,
-                                T::Feet16 => 3,
-                            }
-                        },
-                    ),
-                    // Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
-                    Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
-                    Field::choice(
-                        "colorEngine",
-                        "Color engine",
-                        &[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
-                        usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
-                    ),
-                    Field::choice(
-                        "workingSpace",
-                        "Working space",
-                        &[
-                            ("None", json!("none")),
-                            ("sRGB IEC61966-2.1", json!("srgb")),
-                            ("HDTV (Rec. 709)", json!("rec709")),
-                            ("Rec. 2020", json!("rec2020")),
-                            ("Display P3", json!("p3")),
-                            ("ACEScg", json!("acescg")),
-                            ("ACES2065-1", json!("aces2065")),
-                        ],
-                        st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
-                    ),
-                    // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
-                    Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
-                    Field::bool("linearize", "Linearize working space", st.linearize),
-                    Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
-                    Field::choice(
-                        "hdr",
-                        "HDR on SDR displays and outputs",
-                        &[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
-                        st.hdr as usize,
-                    ),
-                    Field::choice(
-                        "outputSpace",
-                        "Output color space",
-                        &[
-                            ("sRGB IEC61966-2.1 (default)", json!("default")),
-                            ("HDTV (Rec. 709)", json!("rec709")),
-                            ("Rec. 2020", json!("rec2020")),
-                            ("Display P3", json!("p3")),
-                            ("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
-                            ("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
-                        ],
-                        {
-                            use effectcraft_engine::project::ColorSpace as C;
-                            match st.output_space {
-                                Some(C::Rec709) => 1,
-                                Some(C::Rec2020) => 2,
-                                Some(C::DisplayP3) => 3,
-                                Some(C::Rec2100Pq) => 4,
-                                Some(C::Rec2100Hlg) => 5,
-                                _ => 0,
-                            }
-                        },
-                    ),
-                    // Video Rendering and Effects ▸ Use.
-                    Field::choice(
-                        "renderer",
-                        "Video rendering and effects",
-                        &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
-                        usize::from(!st.gpu_acceleration),
-                    ),
-                ],
-            )
+            let Some(form) = interpret_footage_form(s, proxy) else { return false };
+            form
         }
+        "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s),
         "layer.autoTrace" if !has(p, &["channel", "timeSpan", "threshold"]) => (
             "Auto-trace".into(),
             vec![
@@ -662,42 +713,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             )
         }
         // File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL).
-        "file.exportTimeline" if !has(p, &["path"]) => {
-            let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
-            let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
-            let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
-            (
-                "Export Adobe Premiere Pro Project".into(),
-                vec![
-                    Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
-                    Field::save_path("path", "File", &path),
-                    Field::choice(
-                        "format",
-                        "Format",
-                        &[
-                            ("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
-                            ("FCPXML (.fcpxml)", json!("fcpxml")),
-                            ("OpenTimelineIO (.otio)", json!("otio")),
-                            ("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
-                            ("AAF (.aaf)", json!("aaf")),
-                            ("OMF (.omf, audio)", json!("omf")),
-                        ],
-                        0,
-                    ),
-                    Field::choice(
-                        "prerender",
-                        "Pre-render (ProRes 4444)",
-                        &[
-                            ("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
-                            ("All layers", json!("all")),
-                            ("None (leave them out)", json!("none")),
-                        ],
-                        0,
-                    ),
-                    Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
-                ],
-            )
-        }
+        "file.exportTimeline" if !has(p, &["path"]) => export_timeline_form(s),
         "file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
             "New Placeholder".into(),
             vec![
diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs
index b7b09dad4..4ed96797a 100644
--- a/crates/ui-egui/src/panels/fx_editors.rs
+++ b/crates/ui-egui/src/panels/fx_editors.rs
@@ -66,19 +66,9 @@ fn register_points(app: &mut EffectcraftApp, prefix: &str, gr: Rect, pts: &[[f32
 }
 
 /// Small coloured tabs (`names[i]`, swatch `cols[i]`); returns the clicked one.
-fn tabs(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    x0: f32,
-    cy: f32,
-    cur: usize,
-    names: &[&str],
-    cols: &[Color32],
-    id: &str,
-) -> Option {
+fn tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: Pos2, cur: usize, (names, cols): (&[&str], &[Color32]), id: &str) -> Option {
     let t = app.tokens;
-    let mut x = x0;
+    let (mut x, cy) = (at.x, at.y);
     let mut out = None;
     for (i, name) in names.iter().enumerate() {
         let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 };
@@ -188,7 +178,7 @@ fn rgb_curves(
     let cy = r.min.y + 15.0;
     let cols = [Color32::from_gray(0xe8), Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
     let names: Vec<&str> = RGB_CURVES.iter().map(|c| c.1).collect();
-    if let Some(n) = tabs(app, ui, p, x0, cy, ch, &names, &cols, &format!("{prefix}.channel")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, cy), ch, (&names, &cols), &format!("{prefix}.channel")) {
         app.ui.fx_curve_channel.insert(g.uid, n);
     }
     let id = RGB_CURVES[ch].0;
@@ -299,7 +289,7 @@ fn huesat_curves(
     let x0 = r.min.x + 38.0;
     let cy = r.min.y + 15.0;
     let names: Vec<&str> = HUESAT.iter().map(|c| c.1).collect();
-    if let Some(n) = tabs(app, ui, p, x0, cy, k, &names, &[], &format!("{prefix}.curve")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, cy), k, (&names, &[]), &format!("{prefix}.curve")) {
         app.ui.fx_curve_channel.insert(g.uid, n);
     }
     let (id, name, axis, periodic) = HUESAT[k];
@@ -724,7 +714,7 @@ fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, laye
     }
     let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(2);
     let cols = [Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
-    if let Some(n) = tabs(app, ui, p, x0, r.min.y + 15.0, ch, &["Red", "Green", "Blue"], &cols, &format!("{prefix}.channel")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, r.min.y + 15.0), ch, (&["Red", "Green", "Blue"], &cols), &format!("{prefix}.channel")) {
         app.ui.fx_curve_channel.insert(euid, n);
     }
     if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, r.min.y + 6.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) {
diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs
index e917827cc..b23000d27 100644
--- a/crates/ui-egui/src/panels/fx_widgets.rs
+++ b/crates/ui-egui/src/panels/fx_widgets.rs
@@ -59,7 +59,8 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals:
     let (rev, deg) = split_angle(v);
     let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t);
     let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text);
-    let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t);
+    let (dr, new_deg, _) =
+        widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), &widgets::HotSpec { speed: 0.5, range: (-1e9, 1e9), decimals, suffix: "°" }, t);
     let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id);
     let out = match (new_rev, new_deg) {
         (Some(r), _) => Some(r as f64 * 360.0 + deg),
diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs
index 66ba31470..24df22616 100644
--- a/crates/ui-egui/src/panels/graph.rs
+++ b/crates/ui-egui/src/panels/graph.rs
@@ -240,17 +240,16 @@ impl ButtonBar {
 /// at `y`: draws it, registers it for automation and advances the bar. Once a button does not fit
 /// (or a previous one overflowed), the button goes to the "»" menu instead and only the id the
 /// menu picked reports a click. Returns whether this button was chosen.
-fn text_button(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    bar: &mut ButtonBar,
-    y: f32,
-    label: &str,
+/// One bar button for [`text_button`]: label, lit state, automation id and tooltip.
+struct BarBtn<'a> {
+    label: &'a str,
     on: bool,
-    id: &str,
-    tip: &str,
-) -> bool {
+    id: &'a str,
+    tip: &'a str,
+}
+
+fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool {
+    let BarBtn { label, on, id, tip } = btn;
     let t = app.tokens;
     let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0;
     // Once one button overflows, the rest follow it into the menu (they keep their order).
@@ -277,35 +276,44 @@ fn text_button(
     resp.clicked()
 }
 
-/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
-pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) {
-    let t = app.tokens;
-    let ctx = ui.ctx().clone();
-    let footer_h = 28.0;
-    let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
-    let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
-    // Background first so keys and handles (added later) sit on top for hit-testing.
-    let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
-    p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
-    app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
-    let speed = speed_graph(app, comp);
-    let cs = curves(app, comp, ectx, tm, plot, speed);
-    // Just opened, or the graph type changed (value and speed have different units): frame the
-    // graphs (Fit Selection to View, else Fit All Graphs to View).
-    let was_speed_id = egui::Id::unique("graph-was-speed");
-    let type_changed = ctx.data(|d| d.get_temp::(was_speed_id)).is_some_and(|was| was != speed);
-    ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
-    if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed {
-        if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
-            fit_selection(app, &cs, tm, plot, speed);
-        } else {
-            fit_all(app);
+/// What the Graph Editor draws: the composition, its evaluation context and the time mapping.
+#[derive(Clone, Copy)]
+pub(crate) struct GraphView<'a> {
+    pub comp: &'a Comp,
+    pub ectx: &'a EvalCtx<'a>,
+    pub tm: TMap,
+}
+
+/// Show Reference Graph: the other graph type, faint, scaled to the plot.
+fn reference_graph(app: &EffectcraftApp, view: GraphView, plot: Rect, speed: bool, pc: &egui::Painter) {
+    let GraphView { comp, ectx, tm } = view;
+    let other = curves(app, comp, ectx, tm, plot, !speed);
+    let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
+    for c in &other {
+        for (_, v) in &c.pts {
+            rlo = rlo.min(*v);
+            rhi = rhi.max(*v);
         }
     }
+    if rlo.is_finite() {
+        let span = (rhi - rlo).max(1e-9);
+        let lines: Vec<(Color32, Vec)> = other
+            .iter()
+            .map(|c| {
+                (
+                    c.color,
+                    c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05)).collect(),
+                )
+            })
+            .collect();
+        super::graph_tools::reference_graph(pc, plot, &lines);
+    }
+}
 
-    // Vertical range: auto-zoom to the curves + handles, or the stored manual range.
+/// The value range that fits every curve point and key handle (with a margin).
+fn auto_range(cs: &[Curve], speed: bool) -> (f64, f64) {
     let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
-    for c in &cs {
+    for c in cs {
         for (_, v) in &c.pts {
             lo = lo.min(*v);
             hi = hi.max(*v);
@@ -327,7 +335,78 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         hi = lo + 1.0;
     }
     let pad = (hi - lo) * 0.08;
-    let auto = (lo - pad, hi + pad);
+    (lo - pad, hi + pad)
+}
+
+/// Background: a click deselects keys, a drag box-selects the keys inside it.
+fn box_select(ui: &egui::Ui, pc: &egui::Painter, bg: &egui::Response, key_screens: &[(serde_json::Value, Pos2)], accent: Color32, actions: &mut Actions) {
+    let ctx = ui.ctx().clone();
+    if bg.clicked() {
+        actions.push(("keys.select".into(), json!({"keys": []})));
+    }
+    if bg.dragged()
+        && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
+    {
+        let br = Rect::from_two_pos(o, cur);
+        pc.rect_filled(br, 0.0, accent.gamma_multiply(0.12));
+        pc.rect_stroke(br, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle);
+        ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
+    }
+    if bg.drag_stopped()
+        && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("graph-box")))
+    {
+        ctx.data_mut(|d| d.remove::(egui::Id::unique("graph-box")));
+        let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
+        actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
+    }
+}
+
+/// Wheel over the graph with Ctrl zooms the value axis `(lo, hi)` (turns auto-zoom off).
+fn wheel_zoom(app: &mut EffectcraftApp, ui: &egui::Ui, plot: Rect, (lo, hi): (f64, f64)) {
+    if ui.rect_contains_pointer(plot) {
+        let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
+        if zoom && dy.abs() > 0.0 {
+            let k = (-dy as f64 / 200.0).exp();
+            let mid = (lo + hi) / 2.0;
+            app.ui.timeline.graph_auto_zoom = false;
+            app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
+        }
+    }
+}
+
+/// Just opened, or the graph type changed (value and speed have different units): frame the
+/// graphs (Fit Selection to View, else Fit All Graphs to View).
+fn fit_on_open(app: &mut EffectcraftApp, ctx: &egui::Context, cs: &[Curve], tm: TMap, plot: Rect, speed: bool) {
+    let was_speed_id = egui::Id::unique("graph-was-speed");
+    let type_changed = ctx.data(|d| d.get_temp::(was_speed_id)).is_some_and(|was| was != speed);
+    ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
+    if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed {
+        if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
+            fit_selection(app, cs, tm, plot, speed);
+        } else {
+            fit_all(app);
+        }
+    }
+}
+
+/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
+pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, view: GraphView, area: Rect, actions: &mut Actions) {
+    let GraphView { comp, ectx, tm } = view;
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let footer_h = 28.0;
+    let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
+    let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
+    // Background first so keys and handles (added later) sit on top for hit-testing.
+    let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
+    p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
+    app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
+    let speed = speed_graph(app, comp);
+    let cs = curves(app, comp, ectx, tm, plot, speed);
+    fit_on_open(app, &ctx, &cs, tm, plot, speed);
+
+    // Vertical range: auto-zoom to the curves + handles, or the stored manual range.
+    let auto = auto_range(&cs, speed);
     // While a key or handle is being dragged the scale stays put (no feedback loop).
     let drag_id = egui::Id::unique("graph-dragging");
     let dragging = ctx.data(|d| d.get_temp::(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
@@ -358,32 +437,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         );
     }
 
-    // Show Reference Graph: the other graph type, faint, scaled to the plot.
     if app.ui.timeline.graph_reference && !cs.is_empty() {
-        let other = curves(app, comp, ectx, tm, plot, !speed);
-        let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
-        for c in &other {
-            for (_, v) in &c.pts {
-                rlo = rlo.min(*v);
-                rhi = rhi.max(*v);
-            }
-        }
-        if rlo.is_finite() {
-            let span = (rhi - rlo).max(1e-9);
-            let lines: Vec<(Color32, Vec)> = other
-                .iter()
-                .map(|c| {
-                    (
-                        c.color,
-                        c.pts
-                            .iter()
-                            .map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
-                            .collect(),
-                    )
-                })
-                .collect();
-            super::graph_tools::reference_graph(&pc, plot, &lines);
-        }
+        reference_graph(app, view, plot, speed, &pc);
     }
     // Curves.
     for c in &cs {
@@ -523,37 +578,19 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
 
     super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
 
-    // Background: click deselects keys, drag box-selects.
-    if bg.clicked() {
-        actions.push(("keys.select".into(), json!({"keys": []})));
-    }
-    if bg.dragged()
-        && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
-    {
-        let br = Rect::from_two_pos(o, cur);
-        pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
-        pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
-        ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
-    }
-    if bg.drag_stopped()
-        && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("graph-box")))
-    {
-        ctx.data_mut(|d| d.remove::(egui::Id::unique("graph-box")));
-        let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
-        actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
-    }
-    // Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off).
-    if ui.rect_contains_pointer(plot) {
-        let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
-        if zoom && dy.abs() > 0.0 {
-            let k = (-dy as f64 / 200.0).exp();
-            let mid = (lo + hi) / 2.0;
-            app.ui.timeline.graph_auto_zoom = false;
-            app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
-        }
+    box_select(ui, &pc, &bg, &key_screens, t.accent, actions);
+    wheel_zoom(app, ui, plot, (lo, hi));
+    if button_bar(app, ui, p, comp, bar, speed, actions) {
+        fit_selection(app, &cs, tm, plot, speed);
     }
+}
 
-    // Button bar.
+/// The footer button bar: graph type, display toggles, fit, snap and keyframe interpolation.
+/// Returns whether Fit Selection was pressed.
+fn button_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, bar: Rect, speed: bool, actions: &mut Actions) -> bool {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let mut fit_sel = false;
     p.rect_filled(bar, 0.0, t.panel_bg);
     p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
     let y = bar.center().y;
@@ -566,48 +603,71 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         p,
         &mut buttons,
         y,
-        "Auto",
-        auto_type,
-        "autoSelectGraphType",
-        "Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
+        BarBtn {
+            label: "Auto",
+            on: auto_type,
+            id: "autoSelectGraphType",
+            tip: "Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
+        },
     ) {
         app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
     }
     // Choosing a graph type turns Auto-Select off, as in After Effects.
-    if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Value", on: !speed, id: "valueGraph", tip: "Edit Value Graph" }) {
         app.ui.timeline.graph_mode = "value".into();
     }
-    if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Speed", on: speed, id: "speedGraph", tip: "Edit Speed Graph" }) {
         app.ui.timeline.graph_mode = "speed".into();
     }
     buttons.x += 6.0;
-    if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Selected", on: show_sel, id: "showSelected", tip: "Show Selected Properties (else all animated properties)" },
+    ) {
         app.ui.timeline.graph_show_selected = !show_sel;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Auto-zoom", on: auto_on, id: "autoZoom", tip: "Auto-zoom graph height" }) {
         app.ui.timeline.graph_auto_zoom = !auto_on;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit", on: false, id: "fit", tip: "Fit all graphs to view" }) {
         fit_all(app);
     }
-    if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
-        fit_selection(app, &cs, tm, plot, speed);
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit Selection", on: false, id: "fitSelection", tip: "Fit selection to view" }) {
+        fit_sel = true;
     }
     buttons.x += 6.0;
     let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
-    if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Snap", on: snap, id: "snap", tip: "Snap (key drags snap to the current time and other keys)" })
+    {
         app.ui.timeline.graph_snap = !snap;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Reference", on: reference, id: "reference", tip: "Show Reference Graph" }) {
         app.ui.timeline.graph_reference = !reference;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Transform Box", on: tbox, id: "transformBox", tip: "Show Transform Box when multiple keys are selected" },
+    ) {
         app.ui.timeline.graph_transform_box = !tbox;
     }
     buttons.x += 6.0;
     let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
-    if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
-        && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Separate Dimensions", on: false, id: "separateDimensions", tip: "Separate Dimensions (Position)" },
+    ) && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
     {
         actions.push(("prop.separateDimensions".into(), json!({"layer": l})));
     }
@@ -621,11 +681,12 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"),
     ] {
         let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or("");
-        if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) {
+        if text_button(app, ui, p, &mut buttons, y, BarBtn { label, on: false, id: &id, tip }) {
             actions.push((cmd.into(), params));
         }
     }
     buttons.more(app, ui, p, bar);
+    fit_sel
 }
 
 /// Fit All Graphs to View: the whole comp in time, auto-zoomed values.
diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs
index ab26d700f..22e31c468 100644
--- a/crates/ui-egui/src/panels/key_dialogs.rs
+++ b/crates/ui-egui/src/panels/key_dialogs.rs
@@ -125,8 +125,16 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str
     (ok, cancel)
 }
 
-fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) {
-    let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)));
+/// Drag speed, clamp range and suffix of a [`drag`] field.
+struct DragSpec<'a> {
+    speed: f64,
+    range: std::ops::RangeInclusive,
+    suffix: &'a str,
+}
+
+fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, spec: DragSpec, enabled: bool) {
+    let r =
+        ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(spec.speed).range(spec.range).max_decimals(2).suffix(spec.suffix)));
     app.auto.add(id, r.rect, id);
 }
 
@@ -147,13 +155,13 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
                     ui.horizontal(|ui| {
                         ui.label(tr("Speed:"));
                         let v = if out { &mut d.out_speed[dim] } else { &mut d.in_speed[dim] };
-                        drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, 1.0, -1e7..=1e7, "", enabled);
+                        drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, DragSpec { speed: 1.0, range: -1e7..=1e7, suffix: "" }, enabled);
                         ui.label(egui::RichText::new(&d.units).color(t.text_dim));
                     });
                     ui.horizontal(|ui| {
                         ui.label(tr("Influence:"));
                         let v = if out { &mut d.out_inf[dim] } else { &mut d.in_inf[dim] };
-                        drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, 0.2, 0.1..=100.0, " %", enabled);
+                        drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, DragSpec { speed: 0.2, range: 0.1..=100.0, suffix: " %" }, enabled);
                     });
                     ui.end_row();
                 }
@@ -254,14 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
             ui.end_row();
             ui.label(tr("Stretch Factor:"));
             let before = d.percent;
-            drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, 0.5, -10000.0..=10000.0, " %", true);
+            drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true);
             if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 {
                 d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs();
             }
             ui.end_row();
             ui.label(tr("New Duration:"));
             let before = d.duration;
-            drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, 0.05, 0.01..=100000.0, " s", true);
+            drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true);
             if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 {
                 d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration;
             }
diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs
index f6157da47..5fb4d703c 100644
--- a/crates/ui-egui/src/panels/layer_panel.rs
+++ b/crates/ui-egui/src/panels/layer_panel.rs
@@ -87,6 +87,141 @@ fn default_view(layer: &Layer, tool: Tool) -> usize {
     }
 }
 
+/// The layer's rendered view `view` at `scale`, cached in egui's temp data per revision, layer,
+/// time, view and scale (the layer cache makes repeats cheap).
+fn render_tex(
+    app: &EffectcraftApp,
+    ctx: &egui::Context,
+    cid: effectcraft_engine::project::ItemId,
+    comp: &effectcraft_engine::project::Comp,
+    layer: &Layer,
+    view: usize,
+    scale: f64,
+) -> Option {
+    let time = app.session.time();
+    let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
+    let cached: Option = ctx.data(|d| d.get_temp(tex_id()));
+    match cached {
+        Some(c) if c.key == key => Some(c),
+        _ => {
+            let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
+            let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
+            r.expr = app.session.expr.as_deref();
+            r.cache = Some(&app.session.layer_cache);
+            r.layer_input(&ectx, layer, view).map(|buf| {
+                let img = crate::frames::to_color_image(&buf.img);
+                let x0 = -buf.offset[0] / buf.scale;
+                let y0 = -buf.offset[1] / buf.scale;
+                let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
+                let tex = crate::frames::load_fitted(ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
+                let c = Tex { key, tex, rect };
+                ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
+                c
+            })
+        }
+    }
+}
+
+/// What [`paint_stroke`] needs about the brush and the layer <-> screen mapping.
+#[derive(Clone, Copy)]
+struct StrokeCtx<'a> {
+    layer: u64,
+    kind: &'a str,
+    zoom: f64,
+    did: egui::Id,
+    diameter: f64,
+    color: [f64; 4],
+    to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
+    to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
+}
+
+/// Records a paint stroke's layer-space points while dragging, previews it, and runs
+/// `paint.stroke` on release (or on a click).
+fn paint_stroke(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Painter, resp: &egui::Response, sc: &StrokeCtx) {
+    let StrokeCtx { layer, kind, zoom, did, diameter, color, to_layer, to_screen } = *sc;
+    let ctx = ui.ctx().clone();
+    let now = ui.input(|i| i.time);
+    if resp.drag_started()
+        && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
+    {
+        ctx.data_mut(|d| d.insert_temp(did, Drag { layer, points: vec![to_layer(pos)], started: now }));
+    }
+    if let Some(mut d) = ctx.data(|d| d.get_temp::(did)) {
+        if let Some(pos) = resp.interact_pointer_pos() {
+            let lp = to_layer(pos);
+            let last = d.points.last().copied().unwrap_or(lp);
+            if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.0 {
+                d.points.push(lp);
+                ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
+            }
+        }
+        // Live preview of the stroke.
+        let col = if kind == "eraser" {
+            Color32::from_white_alpha(140)
+        } else {
+            Color32::from_rgba_unmultiplied((color[0] * 255.0) as u8, (color[1] * 255.0) as u8, (color[2] * 255.0) as u8, 200)
+        };
+        let width = ((diameter * zoom) as f32).max(1.0);
+        let pts: Vec = d.points.iter().map(|q| to_screen(*q)).collect();
+        if pts.len() == 1 {
+            painter.circle_filled(pts[0], width / 2.0, col);
+        } else {
+            painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
+        }
+        if resp.drag_stopped() {
+            ctx.data_mut(|dd| dd.remove::(did));
+            let pts: Vec<[f64; 2]> = d.points.clone();
+            let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
+            if let Err(e) = r {
+                app.ui.status = e.to_string();
+            }
+        }
+    }
+    if resp.clicked()
+        && let Some(pos) = resp.interact_pointer_pos()
+    {
+        let r = app.session.execute("paint.stroke", json!({"layer": layer, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
+        if let Err(e) = r {
+            app.ui.status = e.to_string();
+        }
+    }
+}
+
+/// The bottom bar: the magnification menu and the View menu over `names` (Masks, then the
+/// effects). Returns where the View dropdown ends.
+fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect, names: &[String], view: usize, (zoom, fit): (f64, f32)) -> f32 {
+    let t = app.tokens;
+    p.rect_filled(bar, 0.0, t.panel_bg);
+    let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
+    if let Some((z, up)) = super::viewer_tools::magnification_menu(
+        app,
+        ui,
+        mr,
+        "layer-mag",
+        "layerPanel.magnification",
+        "layerMagnification",
+        zoom as f32,
+        app.ui.layer_zoom,
+        false,
+    ) {
+        // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %.
+        app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z };
+        app.ui.layer_pan = [0.0, 0.0];
+    }
+    let vx = mr.max.x + 10.0;
+    p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
+    let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
+    let pop = egui::Id::unique("layer-view-pop");
+    if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    app.auto.add("layerPanel.view", vr, "View");
+    if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), names, Some(view)) {
+        app.ui.layer_view = Some(i);
+    }
+    vr.max.x
+}
+
 /// The Layer panel: one layer in its own space with its masks and effects up to the View menu's
 /// choice, rendered synchronously at a zoom/pan the panel keeps (the last Fit magnification is
 /// published for the View menu). Wheel or pinch zooms about the pointer, the Hand tool or
@@ -130,44 +265,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     };
     let origin = area.center() + pan - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
 
-    // Bottom bar: magnification and View menus.
-    p.rect_filled(bar, 0.0, t.panel_bg);
-    let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
-    if let Some((z, up)) = super::viewer_tools::magnification_menu(
-        app,
-        ui,
-        mr,
-        "layer-mag",
-        "layerPanel.magnification",
-        "layerMagnification",
-        zoom as f32,
-        app.ui.layer_zoom,
-        false,
-    ) {
-        // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %.
-        app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z };
-        app.ui.layer_pan = [0.0, 0.0];
-    }
-    let vx = mr.max.x + 10.0;
-    p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
     let mut names = vec!["Masks".to_string()];
     if let Some(fx) = layer.effects() {
         names.extend(fx.groups().map(|g| g.name.clone()));
     }
-    let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
-    let pop = egui::Id::unique("layer-view-pop");
-    if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
-        widgets::open_popup(ui, pop);
-    }
-    app.auto.add("layerPanel.view", vr, "View");
-    if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), &names, Some(view)) {
-        app.ui.layer_view = Some(i);
-    }
+    let vr_right = bottom_bar(app, ui, &p, bar, &names, view, (zoom, fit));
+
     let tc = super::timecode(&app.session, &comp, time);
     p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode);
     // Roto Brush: view / Freeze buttons and the segmentation span bar.
     if roto.is_some() {
-        super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer);
+        super::roto_tool::bar_buttons(app, ui, &p, bar, vr_right + 12.0, &layer);
         let sb = Rect::from_min_max(pos2(rect.min.x, canvas_bottom), pos2(rect.max.x, bar.min.y));
         super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer);
     }
@@ -190,27 +298,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     } else {
         0.25
     };
-    let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
-    let cached: Option = ctx.data(|d| d.get_temp(tex_id()));
-    let tex = match cached {
-        Some(c) if c.key == key => Some(c),
-        _ => {
-            let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
-            let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
-            r.expr = app.session.expr.as_deref();
-            r.cache = Some(&app.session.layer_cache);
-            r.layer_input(&ectx, &layer, view).map(|buf| {
-                let img = crate::frames::to_color_image(&buf.img);
-                let x0 = -buf.offset[0] / buf.scale;
-                let y0 = -buf.offset[1] / buf.scale;
-                let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
-                let tex = crate::frames::load_fitted(&ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
-                let c = Tex { key, tex, rect };
-                ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
-                c
-            })
-        }
-    };
+    let tex = render_tex(app, &ctx, cid, &comp, &layer, view, scale);
     if let Some(tx) = &tex {
         let r = Rect::from_min_max(to_screen([tx.rect[0], tx.rect[1]]), to_screen([tx.rect[2], tx.rect[3]]));
         painter.image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -271,7 +359,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     // Interaction: Roto Brush / Refine Edge.
     if app.ui.tool.roto_kind(false).is_some() {
-        super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer);
+        super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, super::roto_tool::LayerXform { to_screen: &to_screen, to_layer: &to_layer });
         return;
     }
     // Interaction: paint tools.
@@ -314,7 +402,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         painter.line_segment([s - vec2(0.0, 6.0), s + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::WHITE));
     }
     let mods = ui.input(|i| i.modifiers);
-    let now = ui.input(|i| i.time);
     // Alt/Option-click with the Clone Stamp sets the source.
     if app.ui.tool == Tool::Clone
         && mods.alt
@@ -328,49 +415,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     if let Some(kind) = kind
         && !mods.alt
     {
-        if resp.drag_started()
-            && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
-        {
-            ctx.data_mut(|d| d.insert_temp(did, Drag { layer: layer.id.0, points: vec![to_layer(pos)], started: now }));
-        }
-        if let Some(mut d) = ctx.data(|d| d.get_temp::(did)) {
-            if let Some(pos) = resp.interact_pointer_pos() {
-                let lp = to_layer(pos);
-                let last = d.points.last().copied().unwrap_or(lp);
-                if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.0 {
-                    d.points.push(lp);
-                    ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
-                }
-            }
-            // Live preview of the stroke.
-            let col = if kind == "eraser" {
-                Color32::from_white_alpha(140)
-            } else {
-                Color32::from_rgba_unmultiplied((o.color[0] * 255.0) as u8, (o.color[1] * 255.0) as u8, (o.color[2] * 255.0) as u8, 200)
-            };
-            let width = ((o.diameter * zoom) as f32).max(1.0);
-            let pts: Vec = d.points.iter().map(|q| to_screen(*q)).collect();
-            if pts.len() == 1 {
-                painter.circle_filled(pts[0], width / 2.0, col);
-            } else {
-                painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
-            }
-            if resp.drag_stopped() {
-                ctx.data_mut(|dd| dd.remove::(did));
-                let pts: Vec<[f64; 2]> = d.points.clone();
-                let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
-                if let Err(e) = r {
-                    app.ui.status = e.to_string();
-                }
-            }
-        }
-        if resp.clicked()
-            && let Some(pos) = resp.interact_pointer_pos()
-        {
-            let r = app.session.execute("paint.stroke", json!({"layer": layer.id.0, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
-            if let Err(e) = r {
-                app.ui.status = e.to_string();
-            }
-        }
+        let stroke = StrokeCtx { layer: layer.id.0, kind, zoom, did, diameter: o.diameter, color: o.color, to_layer: &to_layer, to_screen: &to_screen };
+        paint_stroke(app, ui, &painter, &resp, &stroke);
     }
 }
diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs
index e07b25766..ffe2cc1f7 100644
--- a/crates/ui-egui/src/panels/media_panels.rs
+++ b/crates/ui-egui/src/panels/media_panels.rs
@@ -63,6 +63,27 @@ fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut
     thumb
 }
 
+/// The Favorites column: one clickable row per place, the current folder `dir` highlighted.
+fn places_column(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, side: Rect, places: &[(String, String)], dir: &str) {
+    let t = app.tokens;
+    let mut fy = side.min.y + 22.0;
+    for (i, (name, path)) in places.iter().enumerate() {
+        let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
+        let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
+        if path == dir {
+            p.rect_filled(r, 0.0, t.row_selected);
+        } else if resp.hovered() {
+            p.rect_filled(r, 0.0, t.hover);
+        }
+        p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
+        app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
+        if resp.clicked() {
+            kit::exec(app, "mediaBrowser.go", json!({"path": path}));
+        }
+        fy += 20.0;
+    }
+}
+
 /// The Media Browser panel: a toolbar (up, home, favourite, "Media only" filter and the browser's
 /// own actions), the current path, the favourite places and a tile grid of the folder's entries
 /// with thumbnails for images and videos. Double-click opens a folder or imports a file; files
@@ -136,26 +157,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 54.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y));
     p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator));
     p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, tr("Favorites"), Tokens::semibold(11.5), t.text_dim);
-    let mut fy = side.min.y + 22.0;
     let mut places: Vec<(String, String)> = b.places();
     places.extend(
         st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())),
     );
-    for (i, (name, path)) in places.iter().enumerate() {
-        let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
-        let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
-        if *path == dir {
-            p.rect_filled(r, 0.0, t.row_selected);
-        } else if resp.hovered() {
-            p.rect_filled(r, 0.0, t.hover);
-        }
-        p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
-        app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
-        if resp.clicked() {
-            kit::exec(app, "mediaBrowser.go", json!({"path": path}));
-        }
-        fy += 20.0;
-    }
+    places_column(app, ui, &p, side, &places, &dir);
     // Files.
     let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
     let entries = match entries.as_ref() {
diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs
index bd0e716b4..dfa9a42e0 100644
--- a/crates/ui-egui/src/panels/misc.rs
+++ b/crates/ui-egui/src/panels/misc.rs
@@ -117,7 +117,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
             let lx = xv + k as f32 * 100.0;
             p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
-            let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::unique(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
+            let (r, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(lx + 52.0, yy - 9.0),
+                egui::Id::unique(("pv-roll", key)),
+                v,
+                &widgets::HotSpec { speed: 0.05, range: (0.0, 600.0), decimals: 1, suffix: "s" },
+                &t,
+            );
             app.auto.add(&format!("preview.{key}"), r, text);
             if let Some(nv) = nv {
                 changes.push(json!({"shortcut": current.id(), key: nv}));
@@ -160,7 +167,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     let sx = xv + 106.0;
     p.text(pos2(sx, yy), Align2::LEFT_CENTER, tr("Skip"), Tokens::ui(12.0), t.text_dim);
-    let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::unique("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
+    let (r, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(sx + 30.0, yy - 9.0),
+        egui::Id::unique("pv-skip"),
+        o.skip as f64,
+        &widgets::HotSpec { speed: 0.1, range: (0.0, 99.0), decimals: 0, suffix: "" },
+        &t,
+    );
     app.auto.add("preview.skip", r, "Skip");
     if let Some(nv) = nv {
         changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
@@ -181,8 +195,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         changes.push(json!({"shortcut": current.id(), "resolution": res_ids[i]}));
     }
     if o.resolution == PreviewResolution::Custom {
-        let (r, nv, _) =
-            widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::unique("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(xv + 106.0, yy - 9.0),
+            egui::Id::unique("pv-custom-res"),
+            o.custom_resolution as f64,
+            &widgets::HotSpec { speed: 0.1, range: (1.0, 40.0), decimals: 0, suffix: "" },
+            &t,
+        );
         app.auto.add("preview.customResolution", r, "Custom Resolution");
         if let Some(nv) = nv {
             changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32}));
diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs
index 43e4d438c..c867eb63a 100644
--- a/crates/ui-egui/src/panels/paint_panels.rs
+++ b/crates/ui-egui/src/panels/paint_panels.rs
@@ -36,7 +36,14 @@ fn number(
     let t = app.tokens;
     p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
     let lw = p.layout_no_wrap(label.to_string(), Tokens::ui(11.5), t.text).size().x;
-    let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::unique(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
+    let (r, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(x + lw + 6.0, y),
+        egui::Id::unique(("paint-num", key)),
+        v,
+        &widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
+        &t,
+    );
     app.auto.add(key, r, label);
     nv.filter(|_| enabled)
 }
@@ -83,6 +90,48 @@ fn paint_swap_icon(p: &egui::Painter, rect: Rect, color: Color32) {
     p.arrow(pos2(right, bottom), vec2(left - right, 0.0), stroke);
 }
 
+/// The foreground-over-background swatches (each opens a colour popup) with Swap and Reset.
+fn colour_swatches(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, color: [f64; 4], background: [f64; 4]) {
+    let t = app.tokens;
+    let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
+    let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
+    widgets::swatch(ui, bg, [background[0] as f32, background[1] as f32, background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
+    let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
+    if bresp.clicked() {
+        widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
+    }
+    if widgets::swatch(ui, fg, [color[0] as f32, color[1] as f32, color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
+        widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
+    }
+    app.auto.add("paint.color", fg, "Foreground color");
+    app.auto.add("paint.background", bg, "Background color");
+    let mut fc = rgb(color);
+    if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
+        set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
+    }
+    let mut bc = rgb(background);
+    if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
+        set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
+    }
+    let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
+    let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
+    // Two arrows (foreground over background): drawn, so the icon does not depend on a font
+    // carrying U+21C4 (the bundled fonts do not).
+    paint_swap_icon(p, swap, if sresp.hovered() { t.text } else { t.text_dim });
+    app.auto.add("paint.swapColors", swap, "Swap colors");
+    if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
+        set(app, json!({"color": background, "background": color}));
+    }
+    let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
+    let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
+    p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
+    p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
+    app.auto.add("paint.resetColors", reset, "Default colors");
+    if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
+        set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
+    }
+}
+
 /// The Paint panel: opacity and flow, foreground/background colours with swap and reset, mode,
 /// channels, duration, erase mode and the Clone Stamp options. Every change is sent as a
 /// `paint.options` command. Mode and Channels are disabled for the Eraser; Erase is enabled
@@ -106,43 +155,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     y += 28.0;
     // Colour swatches (foreground over background) with swap / reset.
-    let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
-    let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
-    widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
-    let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
-    if bresp.clicked() {
-        widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
-    }
-    if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
-        widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
-    }
-    app.auto.add("paint.color", fg, "Foreground color");
-    app.auto.add("paint.background", bg, "Background color");
-    let mut fc = rgb(o.color);
-    if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
-        set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
-    }
-    let mut bc = rgb(o.background);
-    if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
-        set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
-    }
-    let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
-    let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
-    // Two arrows (foreground over background): drawn, so the icon does not depend on a font
-    // carrying U+21C4 (the bundled fonts do not).
-    paint_swap_icon(&p, swap, if sresp.hovered() { t.text } else { t.text_dim });
-    app.auto.add("paint.swapColors", swap, "Swap colors");
-    if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
-        set(app, json!({"color": o.background, "background": o.color}));
-    }
-    let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
-    let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
-    p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
-    p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
-    app.auto.add("paint.resetColors", reset, "Default colors");
-    if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
-        set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
-    }
+    colour_swatches(app, ui, &p, x0, y, o.color, o.background);
     y += 40.0;
 
     // Mode / Channels / Duration.
@@ -167,10 +180,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             pos2(x0 + w - 52.0, y + 1.0),
             egui::Id::unique("paint-custom-frames"),
             o.custom_frames as f64,
-            0.2,
-            (1.0, 10000.0),
-            0,
-            " f",
+            &widgets::HotSpec { speed: 0.2, range: (1.0, 10000.0), decimals: 0, suffix: " f" },
             &t,
         );
         app.auto.add("paint.customFrames", r, "Custom duration (frames)");
@@ -239,7 +249,14 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let off = o.clone_offset.unwrap_or([0.0, 0.0]);
     p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Offset:"), Tokens::ui(11.5), t.text_dim);
     for (d, v) in off.iter().enumerate() {
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::unique(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x0 + 56.0 + d as f32 * 64.0, y),
+            egui::Id::unique(("clone-off", d)),
+            *v,
+            &widgets::HotSpec { speed: 1.0, range: (-1e5, 1e5), decimals: 0, suffix: "" },
+            &t,
+        );
         app.auto.add(&format!("paint.cloneOffset.{d}"), r, "Clone offset");
         if let Some(nv) = nv {
             let mut no = off;
@@ -354,7 +371,14 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     // Spacing (checkbox in AE turns spacing on; we always space dabs, so it shows the value).
     p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Spacing:"), Tokens::ui(11.5), t.text_dim);
     let _ = r;
-    let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::unique("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
+    let (sr, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(x0 + 58.0, y),
+        egui::Id::unique("brushes-spacing"),
+        o.spacing,
+        &widgets::HotSpec { speed: 0.5, range: (1.0, 1000.0), decimals: 0, suffix: " %" },
+        &t,
+    );
     app.auto.add("brushes.spacing", sr, "Spacing");
     if let Some(nv) = nv {
         set(app, json!({"spacing": nv}));
diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs
index 99554ec57..501ddf64f 100644
--- a/crates/ui-egui/src/panels/panel_kit.rs
+++ b/crates/ui-egui/src/panels/panel_kit.rs
@@ -47,7 +47,7 @@ pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) {
 #[allow(clippy::too_many_arguments)]
 pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
     let t = app.tokens;
-    let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, speed, range, dec, suffix, &t);
+    let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, &widgets::HotSpec { speed, range, decimals: dec, suffix }, &t);
     app.auto.add(auto, r, &format!("{v}"));
     nv.unwrap_or(v)
 }
diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs
index 0d2769541..1944f333f 100644
--- a/crates/ui-egui/src/panels/project.rs
+++ b/crates/ui-egui/src/panels/project.rs
@@ -381,17 +381,9 @@ pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[Ite
     app.show_panel(crate::dock::PanelKind::Project);
 }
 
-/// The Project panel: the selected item's thumbnail and details (with the comps that use it), the
-/// search field, sortable column headers, and the item rows (folders, comps, footage and solids)
-/// with selection, Shift/Cmd ranges, keyboard navigation, inline rename, drag-and-drop move,
-/// marquee selection and context menus. Only the rows in view are drawn. File commands are queued
-/// and run through [`crate::menus::invoke`] at the end, with failures shown in `app.ui.status`.
-pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+/// The selected item's thumbnail and details (with the comps that use it), or a hint.
+fn details_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, ctx: &egui::Context, head: Rect) {
     let t = app.tokens;
-    let p = ui.painter().clone();
-    let ctx = ui.ctx().clone();
-    // Preview header.
-    let head = Rect::from_min_size(rect.min, vec2(rect.width(), 74.0));
     let sel = app.session.state.project_selection.first().copied();
     if let Some(it) = sel.and_then(|i| app.session.project.item(i)).cloned() {
         let thumb = Rect::from_min_size(head.min + vec2(10.0, 10.0), vec2(96.0, 54.0));
@@ -400,12 +392,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ItemKind::Solid(s) => {
                 p.rect_filled(thumb.shrink(4.0), 2.0, Color32::from_rgb((s.color[0] * 255.0) as u8, (s.color[1] * 255.0) as u8, (s.color[2] * 255.0) as u8));
             }
-            _ => match thumbnail(app, &ctx, &it) {
+            _ => match thumbnail(app, ctx, &it) {
                 Some(tex) => {
                     let [w, h] = tex.size();
                     p.image(tex.id(), fit(thumb, w as f32, h as f32), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
                 }
-                None => icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
+                None => icons::paint(p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
             },
         }
         app.auto.add("project.thumbnail", thumb, &it.name);
@@ -418,7 +410,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let count = users.iter().fold(0usize, |n, (_, k)| n.saturating_add(*k));
         let name_y = thumb.min.y + 6.0;
         if count == 0 {
-            let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text);
+            let r = widgets::text_fit(p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text);
             app.auto.add("project.details.name", r, &it.name);
         } else {
             let used = if count == 1 { crate::i18n::tr("used once").to_owned() } else { crate::i18n::tr_args("used {} times", &[&count]) };
@@ -427,17 +419,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let arrow_w = 14.0;
             // The name gives way first, but keeps at least half the room; the count is cut after it.
             let name_max = (max_w - arrow_w - used_w).max(max_w * 0.5);
-            let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text);
+            let r = widgets::text_fit(p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text);
             app.auto.add("project.details.name", r, &it.name);
             let ar = Rect::from_min_size(pos2(r.max.x + 1.0, name_y - 7.0), vec2(arrow_w, 14.0));
             let tip = crate::i18n::tr("Compositions that use this item");
             let aresp = ui.interact(ar, egui::Id::unique("proj-used-in"), Sense::click()).on_hover_text(tip);
-            icons::paint(&p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
+            icons::paint(p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
             app.auto.add("project.details.usedIn", ar, tip);
             // The comma sits right after the arrow (its glyph ends short of the hit area).
             let ux = ar.max.x - 3.0;
             let room = (head.max.x - 10.0 - ux).max(0.0);
-            let ur = widgets::text_fit(&p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim);
+            let ur = widgets::text_fit(p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim);
             app.auto.add("project.details.used", ur, &used);
             let names: Vec<(u64, String, usize)> =
                 users.iter().map(|(cid, n)| (cid.0, app.session.project.item(*cid).map(|c| c.name.clone()).unwrap_or_default(), *n)).collect();
@@ -454,7 +446,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 }
             });
             if let Some(cid) = open
-                && let Err(e) = crate::menus::invoke(app, &ctx, "comp.open", json!({"comp": cid}))
+                && let Err(e) = crate::menus::invoke(app, ctx, "comp.open", json!({"comp": cid}))
             {
                 app.ui.status = e;
             }
@@ -471,23 +463,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             lines.push(f.codec.clone());
         }
         for (i, l) in lines.iter().enumerate() {
-            let r = widgets::text_fit(&p, pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), max_w, t.text_dim);
+            let r = widgets::text_fit(p, pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), max_w, t.text_dim);
             app.auto.add(&format!("project.details.{i}"), r, l);
         }
     } else {
         let hint = "Select an item to see its details";
-        let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
+        let r = widgets::text_fit(p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
         app.auto.add("project.details.hint", r, hint);
     }
-    // Search.
-    let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0));
-    let mut q = app.ui.project_search.clone();
-    widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t);
-    app.ui.project_search = q;
-    app.auto.add("project.search", sr, "Search");
-    // Column header: Name, Label, then the visible optional columns.
-    let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
-    p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+}
+
+/// Column positions: Name and Label, then the visible optional columns (scrolled by `hscroll`).
+struct Columns {
+    cols: Vec<(&'static str, &'static str, f32, f32)>,
+    name_w: f32,
+    label_x: f32,
+    opt_x0: f32,
+    /// How far the optional columns overflow the panel.
+    overflow: f32,
+    hscroll: f32,
+    /// Where the last column ends.
+    end_x: f32,
+}
+
+impl Columns {
+    /// `r` cut off under the frozen Name and Label columns.
+    fn opt_clip(&self, r: Rect) -> Rect {
+        Rect::from_min_max(pos2(r.min.x.max(self.opt_x0 - 4.0), r.min.y), r.max)
+    }
+}
+
+fn layout_columns(app: &mut EffectcraftApp, rect: Rect) -> Columns {
     let name_w = (rect.width() * 0.40).clamp(120.0, 260.0);
     let label_x = rect.min.x + name_w + 14.0;
     let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)];
@@ -505,11 +511,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             x += w + 4.0;
         }
     }
-    let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max);
+    Columns { cols, name_w, label_x, opt_x0, overflow, hscroll, end_x: x }
+}
+
+/// The column header row: sort labels with the sort arrow, and the Columns menu.
+fn draw_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, hdr: Rect, cl: &Columns) {
+    let t = app.tokens;
+    let Columns { cols, label_x, end_x, .. } = cl;
+    let (label_x, end_x) = (*label_x, *end_x);
     let hp = p.with_clip_rect(hdr);
-    for (key, label, x, w) in &cols {
+    for (key, label, x, w) in cols {
         let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height()));
-        let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) };
+        let hr = if matches!(*key, "name" | "label") { hr } else { cl.opt_clip(hr) };
         if hr.width() <= 0.0 {
             continue;
         }
@@ -553,446 +566,202 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         column_menu(app, &resp);
     }
-    let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
+    let hresp = ui.interact(Rect::from_min_max(pos2(end_x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
     column_menu(app, &hresp);
     app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
-    // Rows.
-    let footer_h = 28.0;
-    let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
-    let lp = p.with_clip_rect(list);
-    // The list's empty area (under the rows and the scroll bar, which take their own clicks):
-    // a click deselects, a drag draws a selection box, a double-click imports, as in After
-    // Effects (File ▸ Import ▸ File...).
-    // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
-    let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
-    app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
-    if overflow > 0.0 && ui.rect_contains_pointer(list) {
-        let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
-        let d = if dx.abs() > 0.0 {
-            dx
-        } else if shift {
-            dy
-        } else {
-            0.0
-        };
-        app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
+}
+
+/// An item row's context menu: Composition Settings, Delete, Rename, Duplicate, labels and the
+/// footage commands.
+fn item_menu(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    ctx: &egui::Context,
+    it: &effectcraft_engine::project::Item,
+    id: ItemId,
+    selected: bool,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let footage = matches!(it.kind, ItemKind::Footage(_));
+    // First on a comp, as in After Effects; for this comp, not the viewer's (#1058).
+    if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() {
+        actions.push(("comp.settings".into(), json!({"comp": id.0})));
+        ui.close();
     }
-    let moved = keyboard_nav(app, &ctx);
-    let rows = visible_rows(app);
-    // Enter renames the selected item (Project panel focused, not typing).
-    if app.ui.focused == crate::dock::PanelKind::Project
-        && app.dialog.is_none()
-        && keys_reach_rows(app, &ctx)
-        && ctx.data(|d| d.get_temp::(edit_id())).is_none()
-        && ctx.input(|i| i.key_pressed(egui::Key::Enter))
-    {
-        begin_rename(app, &ctx);
+    if ui.button(crate::i18n::tr("Delete")).clicked() {
+        actions.push(("project.delete".into(), json!({"items": targets(app, id, selected)})));
+        ui.close();
     }
-    let editing: Option = ctx.data(|d| d.get_temp(edit_id()));
-    let dragging = egui::DragAndDrop::payload::(&ctx).and_then(|p| match *p {
-        DragPayload::Item(i) => Some(i),
-        _ => None,
+    if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Open Composition")).clicked() {
+        actions.push(("comp.open".into(), json!({"comp": id.0})));
+        ui.close();
+    }
+    if ui.button(crate::i18n::tr("Rename")).clicked() {
+        ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone())));
+        ui.close();
+    }
+    let duplicate_items = targets(app, id, selected);
+    // Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
+    let can_duplicate = !duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
+    let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
+    if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
+        duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
+    }
+    let response = ui.add_enabled(can_duplicate, duplicate);
+    app.auto.add("project.context.duplicate", response.rect, "Duplicate");
+    if response.clicked() {
+        actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
+        ui.close();
+    }
+    if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
+        actions.push(("project.move".into(), json!({"items": targets(app, id, selected), "folder": null})));
+        ui.close();
+    }
+    if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() {
+        actions.push(("layer.addItem".into(), json!({"item": id.0})));
+        ui.close();
+    }
+    // File ▸ New Comp from Selection on the selection (the right-clicked row joins it
+    // above): several items ask how in its dialog, as in After Effects (#360).
+    if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() {
+        actions.push(("file.newCompFromSelection".into(), json!({})));
+        ui.close();
+    }
+    // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a
+    // new composition holding it instead. The label says how many layers that changes.
+    let uses: usize = app.session.project.item_users(id).iter().map(|(_, n)| n).sum();
+    let label = match uses {
+        0 => crate::i18n::tr("Replace with Precomposition").to_owned(),
+        1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")),
+        n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])),
+    };
+    if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() {
+        actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0})));
+        ui.close();
+    }
+    ui.separator();
+    ui.menu_button(crate::i18n::tr("Label"), |ui| {
+        for l in Label::ALL {
+            if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
+                actions.push(("project.setLabel".into(), json!({"items": targets(app, id, selected), "label": label_index(l)})));
+                ui.close();
+            }
+        }
     });
-    let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
-    let mut drop_row: Option = None;
-    let mut actions: Vec<(String, serde_json::Value)> = vec![];
-    // Vertical scrolling: only the rows in view are drawn (and registered), so a project with
-    // thousands of items costs what one screenful does.
-    let content_h = rows.len() as f32 * ROW_H;
-    let max_scroll = (content_h - list.height() + ROW_H).max(0.0);
-    if ui.rect_contains_pointer(list) {
-        let (dy, shift) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.shift));
-        if !shift && dy != 0.0 {
-            app.ui.project_scroll -= dy;
-        }
+    // Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
+    if footage {
+        let mut chosen = None;
+        crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
+        crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
+        crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
+        crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
+        actions.extend(chosen);
     }
-    // A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
-    let sel_key = selection_seen_id();
-    let sel_now = app.session.state.project_selection.last().map(|i| i.0);
-    if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) {
-        ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
-        if let Some(row) = sel_now.and_then(|s| rows.iter().position(|(id, _)| id.0 == s)) {
-            let top = row as f32 * ROW_H;
-            if top < app.ui.project_scroll {
-                app.ui.project_scroll = top;
-            } else if top + ROW_H > app.ui.project_scroll + list.height() {
-                app.ui.project_scroll = top + ROW_H - list.height();
-            }
-        }
+}
+
+/// The inline rename / comment field over `cell` when it is item `id`'s `field` being edited;
+/// returns whether it took the cell.
+fn cell_edit(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    actions: &mut Vec<(String, serde_json::Value)>,
+    editing: &Option,
+    id: u64,
+    field: &str,
+    cell: Rect,
+) -> bool {
+    let ctx = ui.ctx().clone();
+    let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id && f == field) else { return false };
+    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell));
+    // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no
+    // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel.
+    let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() };
+    let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter));
+    app.auto.add(&format!("project.item.{}.{field}Edit", id), cell, field);
+    let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab));
+    if tab {
+        resp.surrender_focus();
     }
-    // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
-    if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
-        let top = row as f32 * ROW_H;
-        // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
-        app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
-    }
-    app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
-    let scroll = app.ui.project_scroll;
-    let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
-    let mut y = list.min.y - (scroll - first as f32 * ROW_H);
-    let project = app.session.project.clone();
-    for (i, (id, depth)) in rows.iter().enumerate().skip(first) {
-        let Some(it) = project.item(*id) else { continue };
-        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;
-        }
-        if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) {
-            drop_row = Some(*id);
-        }
-        let selected = app.session.state.project_selection.contains(id);
-        lp.rect_filled(
-            r,
-            0.0,
-            // Flat rows like After Effects' Project panel (no stripes).
-            if selected { t.row_selected } else { t.row },
-        );
-        // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
-        // on top of it take their own clicks.
-        let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
-        app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
-        // For screen readers: a tree item named by the item and its type, with its selection,
-        // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
-        let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
-        resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
-        ctx.accesskit_node_builder(resp.id, |n| {
-            n.set_selected(selected);
-            n.set_level(depth.saturating_add(1));
-            if let Some(open) = open {
-                n.set_expanded(open);
-            }
-        });
-        if resp.has_focus() {
-            ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
-        }
-        if resp.gained_focus() {
-            app.ui.focused = crate::dock::PanelKind::Project;
-        }
-        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);
-            let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
-            let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", id.0)), &t);
-            tresp.widget_info(|| {
-                let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
-                egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-            });
-            if tresp.clicked() {
-                // Alt/Cmd-click twirls every folder inside it too.
-                let mut ids = vec![*id];
-                if ui.input(|i| i.modifiers.alt || i.modifiers.command) {
-                    let mut at = 0;
-                    while let Some(f) = ids.get(at).copied() {
-                        ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id));
-                        at += 1;
-                    }
-                }
-                for f in ids {
-                    if open {
-                        app.ui.project_open_folders.remove(&f.0);
-                    } else {
-                        app.ui.project_open_folders.insert(f.0);
-                    }
-                }
-            }
-            app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name);
-        }
-        icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim);
-        // Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
-        if let Some(px) = it.proxy.as_ref() {
-            let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
-            if px.enabled {
-                lp.rect_filled(pr, 1.0, t.text);
+    if resp.lost_focus() || tab {
+        ctx.data_mut(|d| d.remove::(edit_id()));
+        if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
+            let (cmd, params) = if ef == "name" {
+                ("project.rename", json!({"item": eid, "name": buf}))
             } else {
-                lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
-            }
-            let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
-                "Proxy: {} ({})",
-                px.footage.path,
-                if px.enabled { "in use" } else { "off" }
-            ));
-            app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
-            presp.widget_info(|| {
-                let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
-                egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-            });
-            if presp.clicked() {
-                actions.push(("file.useProxy".into(), json!({"item": id.0})));
-            }
-        }
-        // Footage that can't be decoded (no decoder for its codec / profile): a warning marker
-        // at the end of the name cell, the reason on hover.
-        let problem = match &it.kind {
-            ItemKind::Footage(f) => app.session.footage.problem(f),
-            _ => None,
-        };
-        let name_clip =
-            Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list);
-        if let Some(why) = &problem {
-            let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
-            lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
-            lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
-            ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str());
-            app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
-        }
-        let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
-        // Inline edits (rename / comment).
-        let cell_edit = |app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Vec<(String, serde_json::Value)>, field: &str, cell: Rect| -> bool {
-            let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id.0 && f == field) else { return false };
-            let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell));
-            // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no
-            // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel.
-            let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() };
-            let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter));
-            app.auto.add(&format!("project.item.{}.{field}Edit", id.0), cell, field);
-            let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab));
-            if tab {
-                resp.surrender_focus();
-            }
-            if resp.lost_focus() || tab {
-                ctx.data_mut(|d| d.remove::(edit_id()));
-                if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
-                    let (cmd, params) = if ef == "name" {
-                        ("project.rename", json!({"item": eid, "name": buf}))
-                    } else {
-                        ("project.setComment", json!({"items": [eid], "comment": buf}))
-                    };
-                    actions.push((cmd.into(), params));
-                }
-            } else {
-                widgets::keep_focus(&ctx, &resp, &buf);
-                ctx.data_mut(|d| d.insert_temp::(edit_id(), (eid, ef, buf)));
-            }
-            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));
-            }
-            // Long names end in "…" before the label column instead of being cut mid-glyph.
-            widgets::text_fit(
-                &lp.with_clip_rect(name_clip),
-                pos2(x0 + 30.0, r.center().y),
-                Align2::LEFT_CENTER,
-                &it.name,
-                Tokens::ui(12.0),
-                name_clip.max.x - (x0 + 30.0) - 2.0,
-                if selected { Color32::from_gray(0x16) } else { t.text },
-            );
-        }
-        // Label swatch: click for the label colour menu.
-        let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
-        lp.rect_filled(sw, 2.0, t.label(it.label));
-        let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", id.0)), Sense::click());
-        app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
-        sresp.widget_info(|| {
-            let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
-            egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-        });
-        let pop = egui::Id::unique(("plabel-pop", id.0));
-        if sresp.clicked() || sresp.secondary_clicked() {
-            widgets::open_popup(ui, pop);
-        }
-        if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
-            actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
-        }
-        // Optional columns.
-        for (key, _, cx, w) in cols.iter().skip(2) {
-            let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
-            if cell.width() <= 0.0 {
-                continue;
-            }
-            let cp = lp.with_clip_rect(cell);
-            let txt = match *key {
-                "type" => it.type_name().to_string(),
-                "size" => file_size(it).map(fmt_size).unwrap_or_default(),
-                "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
-                "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
-                "path" => file_path(it).to_string(),
-                "comment" => {
-                    let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0));
-                    if cell_edit(app, ui, &mut actions, "comment", cr) {
-                        continue;
-                    }
-                    let cresp = ui.interact(cell, egui::Id::unique(("pcomment", id.0)), Sense::CLICK);
-                    app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
-                    cresp.widget_info(|| {
-                        egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
-                    });
-                    if cresp.double_clicked() {
-                        ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "comment".into(), it.comment.clone())));
-                    } else {
-                        // Other clicks select the row and open its context menu.
-                        resp = resp.union(cresp);
-                    }
-                    it.comment.clone()
-                }
-                _ => String::new(),
+                ("project.setComment", json!({"items": [eid], "comment": buf}))
             };
-            let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER };
-            let tx = if *key == "fps" { cx + w - 6.0 } else { *cx };
-            cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim);
+            actions.push((cmd.into(), params));
         }
-        if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
-            lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
-        }
-        // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
-        if ctx.memory(|m| m.has_focus(row_id(*id))) {
-            lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
-        }
-        // Part of the row: not a second Tab stop.
-        let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
-        app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
-        let resp = resp.union(nresp);
-        if resp.clicked() {
-            let m = ui.input(|i| i.modifiers);
-            let anchor_key = anchor_id();
-            ctx.data_mut(|d| d.insert_temp(cursor_id(), *id));
-            let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
-            if m.shift
-                && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
-            {
-                let b = rows.iter().position(|(i, _)| i == id).unwrap_or(a);
-                app.session.state.project_selection = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
-            } else if m.command {
-                if app.session.state.project_selection.contains(id) {
-                    app.session.state.project_selection.retain(|i| i != id);
-                } else {
-                    app.session.state.project_selection.push(*id);
-                }
-                ctx.data_mut(|d| d.insert_temp(anchor_key, *id));
-            } else {
-                app.session.state.project_selection = vec![*id];
-                ctx.data_mut(|d| d.insert_temp(anchor_key, *id));
-            }
-        }
-        // 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) => {
-                    app.ui.project_open_folders.insert(id.0);
-                }
-                // Footage (not data) and solids open in the Footage panel.
-                ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => {
-                    actions.push(("footage.open".into(), json!({"item": id.0})))
-                }
-                ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))),
-                _ => {}
-            }
-        }
-        if resp.drag_started() {
-            egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
-        }
-        // Right-clicking an item selects it (After Effects): the menu's File commands act on the
-        // selection.
-        if resp.secondary_clicked() && !selected {
-            app.session.state.project_selection = vec![*id];
-        }
-        let footage = matches!(it.kind, ItemKind::Footage(_));
-        crate::widgets::context_menu(&resp, |ui| {
-            // First on a comp, as in After Effects; for this comp, not the viewer's (#1058).
-            if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() {
-                actions.push(("comp.settings".into(), json!({"comp": id.0})));
-                ui.close();
-            }
-            if ui.button(crate::i18n::tr("Delete")).clicked() {
-                actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)})));
-                ui.close();
-            }
-            if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Open Composition")).clicked() {
-                actions.push(("comp.open".into(), json!({"comp": id.0})));
-                ui.close();
-            }
-            if ui.button(crate::i18n::tr("Rename")).clicked() {
-                ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone())));
-                ui.close();
-            }
-            let duplicate_items = targets(app, *id, selected);
-            // Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
-            let can_duplicate =
-                !duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
-            let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
-            if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
-                duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
-            }
-            let response = ui.add_enabled(can_duplicate, duplicate);
-            app.auto.add("project.context.duplicate", response.rect, "Duplicate");
-            if response.clicked() {
-                actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
-                ui.close();
-            }
-            if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
-                actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null})));
-                ui.close();
-            }
-            if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() {
-                actions.push(("layer.addItem".into(), json!({"item": id.0})));
-                ui.close();
-            }
-            // File ▸ New Comp from Selection on the selection (the right-clicked row joins it
-            // above): several items ask how in its dialog, as in After Effects (#360).
-            if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() {
-                actions.push(("file.newCompFromSelection".into(), json!({})));
-                ui.close();
-            }
-            // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a
-            // new composition holding it instead. The label says how many layers that changes.
-            let uses: usize = app.session.project.item_users(*id).iter().map(|(_, n)| n).sum();
-            let label = match uses {
-                0 => crate::i18n::tr("Replace with Precomposition").to_owned(),
-                1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")),
-                n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])),
-            };
-            if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() {
-                actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0})));
-                ui.close();
-            }
-            ui.separator();
-            ui.menu_button(crate::i18n::tr("Label"), |ui| {
-                for l in Label::ALL {
-                    if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
-                        actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
-                        ui.close();
-                    }
-                }
-            });
-            // Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
-            if footage {
-                let mut chosen = None;
-                crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
-                crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
-                crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
-                crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
-                actions.extend(chosen);
-            }
-        });
+    } else {
+        widgets::keep_focus(&ctx, &resp, &buf);
+        ctx.data_mut(|d| d.insert_temp::(edit_id(), (eid, ef, buf)));
     }
-    // The scroll bars, over the rows (which would take their presses otherwise).
-    if overflow > 0.0 {
-        let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
-        app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t);
+    true
+}
+
+/// A click on item `id`'s row: plain selects it, Shift extends from the anchor and Cmd toggles.
+fn select_click(app: &mut EffectcraftApp, ui: &egui::Ui, ctx: &egui::Context, id: ItemId, rows: &[(ItemId, usize)]) {
+    let m = ui.input(|i| i.modifiers);
+    let anchor_key = anchor_id();
+    ctx.data_mut(|d| d.insert_temp(cursor_id(), id));
+    let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
+    if m.shift
+        && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
+    {
+        let b = rows.iter().position(|(i, _)| *i == id).unwrap_or(a);
+        app.session.state.project_selection = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
+    } else if m.command {
+        if app.session.state.project_selection.contains(&id) {
+            app.session.state.project_selection.retain(|i| *i != id);
+        } else {
+            app.session.state.project_selection.push(id);
+        }
+        ctx.data_mut(|d| d.insert_temp(anchor_key, id));
+    } else {
+        app.session.state.project_selection = vec![id];
+        ctx.data_mut(|d| d.insert_temp(anchor_key, id));
     }
-    if max_scroll > 0.0 {
-        let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
-        app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t);
+}
+
+/// Double-click opens the item, as in After Effects: a comp in its Timeline and the viewer, a
+/// folder toggles, footage (not data) and solids open in the Footage panel.
+fn open_item(app: &mut EffectcraftApp, it: &effectcraft_engine::project::Item, id: ItemId, actions: &mut Vec<(String, serde_json::Value)>) {
+    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) => {
+            app.ui.project_open_folders.insert(id.0);
+        }
+        // Footage (not data) and solids open in the Footage panel.
+        ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => actions.push(("footage.open".into(), json!({"item": id.0}))),
+        ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))),
+        _ => {}
     }
+}
+
+/// What the pointer is doing over the list: the item being dragged, the row under it, whether it
+/// is over the list at all.
+struct DropState {
+    dragging: Option,
+    row: Option,
+    hovering: bool,
+}
+
+/// Dragging an item or files over the list: drop outlines, moving items into folders, importing
+/// files and the drag ghost.
+fn drop_targets(app: &mut EffectcraftApp, ctx: &egui::Context, lp: &egui::Painter, list: Rect, ds: DropState, actions: &mut Vec<(String, serde_json::Value)>) {
+    let t = app.tokens;
+    let DropState { dragging, row, hovering } = ds;
     // Dropping a dragged item on the list: into the folder under the pointer (or the folder of
     // the item under it), or the project root below the rows.
     if let Some(iid) = dragging
-        && hover_y.is_some()
+        && hovering
     {
-        if drop_row.is_none() {
+        if row.is_none() {
             lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
         }
-        if ctx.input(|i| i.pointer.any_released()) && drop_row != Some(ItemId(iid)) {
-            let folder = drop_folder(&app.session.project, drop_row);
+        if ctx.input(|i| i.pointer.any_released()) && row != Some(ItemId(iid)) {
+            let folder = drop_folder(&app.session.project, row);
             let cur = app.session.project.item(ItemId(iid)).and_then(|i| i.parent);
             if folder != cur {
                 let moving: Vec = if app.session.state.project_selection.contains(&ItemId(iid)) {
@@ -1005,21 +774,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     app.ui.project_open_folders.insert(f.0);
                 }
             }
-            egui::DragAndDrop::clear_payload(&ctx);
+            egui::DragAndDrop::clear_payload(ctx);
         }
     }
     // Files dragged from the Media Browser: import.
-    if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::(&ctx).as_deref()
-        && hover_y.is_some()
+    if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::(ctx).as_deref()
+        && hovering
     {
         lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
         if ctx.input(|i| i.pointer.any_released()) {
             actions.push(("mediaBrowser.import".into(), json!({"paths": paths})));
-            egui::DragAndDrop::clear_payload(&ctx);
+            egui::DragAndDrop::clear_payload(ctx);
         }
     }
     // Drag ghost.
-    if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(&ctx).as_deref()
+    if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(ctx).as_deref()
         && let Some(pos) = ctx.pointer_hover_pos()
         && let Some(it) = app.session.project.item(ItemId(*iid))
     {
@@ -1029,8 +798,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230));
         painter.galley(r.min + vec2(8.0, 4.0), g, t.text);
     }
-    // Footer.
-    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
+}
+
+/// The footer: Interpret Footage, New Folder, New Composition, the colour depth and the trash.
+fn footer(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, foot: Rect, actions: &mut Vec<(String, serde_json::Value)>) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
     p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
     let mut x = foot.min.x + 8.0;
     for (icon, id, tip) in [
@@ -1092,23 +865,102 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(("project.delete".into(), json!({})));
     }
     app.auto.add("project.delete", tr, "Delete");
-    if empty.double_clicked() {
-        actions.push(("file.import".into(), json!({})));
-    }
-    crate::widgets::context_menu(&empty, |ui| {
-        for (key, label, command) in
-            [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")]
-        {
-            let response = ui.button(label);
-            app.auto.add(&format!("project.context.{key}"), response.rect, label);
-            if response.clicked() {
-                actions.push((command.into(), json!({})));
-                ui.close();
-            }
+}
+
+/// The optional columns of a row (type, size, duration, fps, path, comment). Returns the comment
+/// cell's response so its clicks select the row and open its context menu.
+fn optional_cells(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    cl: &Columns,
+    editing: &Option,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) -> Option {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let RowGeom { lp, list, r, .. } = *g;
+    let mut extra = None;
+    for (key, _, cx, w) in cl.cols.iter().skip(2) {
+        let cell = cl.opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
+        if cell.width() <= 0.0 {
+            continue;
         }
-    });
-    // Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
-    let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
+        let cp = lp.with_clip_rect(cell);
+        let txt = match *key {
+            "type" => it.type_name().to_string(),
+            "size" => file_size(it).map(fmt_size).unwrap_or_default(),
+            "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
+            "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
+            "path" => file_path(it).to_string(),
+            "comment" => {
+                let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0));
+                if cell_edit(app, ui, actions, editing, it.id.0, "comment", cr) {
+                    continue;
+                }
+                let cresp = ui.interact(cell, egui::Id::unique(("pcomment", it.id.0)), Sense::CLICK);
+                app.auto.add(&format!("project.item.{}.comment", it.id.0), cell, &it.comment);
+                cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment])));
+                if cresp.double_clicked() {
+                    ctx.data_mut(|d| d.insert_temp::(edit_id(), (it.id.0, "comment".into(), it.comment.clone())));
+                } else {
+                    // Other clicks select the row and open its context menu.
+                    extra = Some(cresp);
+                }
+                it.comment.clone()
+            }
+            _ => String::new(),
+        };
+        let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER };
+        let tx = if *key == "fps" { cx + w - 6.0 } else { *cx };
+        cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim);
+    }
+    extra
+}
+
+/// Shift+wheel / trackpad over the list scrolls the optional columns sideways.
+fn hscroll_wheel(app: &mut EffectcraftApp, ui: &egui::Ui, list: Rect, overflow: f32) {
+    if overflow > 0.0 && ui.rect_contains_pointer(list) {
+        let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
+        let d = if dx.abs() > 0.0 {
+            dx
+        } else if shift {
+            dy
+        } else {
+            0.0
+        };
+        app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
+    }
+}
+
+/// Enter renames the selected item (Project panel focused, not typing).
+fn enter_renames(app: &mut EffectcraftApp, ctx: &egui::Context) {
+    if app.ui.focused == crate::dock::PanelKind::Project
+        && app.dialog.is_none()
+        && keys_reach_rows(app, ctx)
+        && ctx.data(|d| d.get_temp::(edit_id())).is_none()
+        && ctx.input(|i| i.key_pressed(egui::Key::Enter))
+    {
+        begin_rename(app, ctx);
+    }
+}
+
+/// The warning marker at the end of the name cell for footage that can't be decoded.
+fn problem_marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &RowGeom, name_w: f32, id: ItemId, why: &str) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, .. } = *g;
+    let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
+    lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
+    lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
+    ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why);
+    app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
+}
+
+/// Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
+fn marquee_select(app: &mut EffectcraftApp, ctx: &egui::Context, lp: &egui::Painter, list: Rect, empty: &egui::Response, rows: &[(ItemId, usize)]) {
+    let t = app.tokens;
+    let adding = ctx.input(|i| i.modifiers.shift || i.modifiers.command);
     if empty.clicked() && !adding {
         app.session.state.project_selection.clear();
     }
@@ -1142,6 +994,333 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// Where a row is drawn: its clipped painter, the list, the row rectangle and the indented x.
+#[derive(Clone, Copy)]
+struct RowGeom<'a> {
+    lp: &'a egui::Painter,
+    list: Rect,
+    r: Rect,
+    x0: f32,
+}
+
+/// Vertical scrolling: only the rows in view are drawn (and registered), so a project with
+/// thousands of items costs what one screenful does. Returns the scroll offset and its maximum.
+fn update_scroll(app: &mut EffectcraftApp, ui: &egui::Ui, ctx: &egui::Context, list: Rect, rows: &[(ItemId, usize)], moved: Option) -> (f32, f32) {
+    let content_h = rows.len() as f32 * ROW_H;
+    let max_scroll = (content_h - list.height() + ROW_H).max(0.0);
+    if ui.rect_contains_pointer(list) {
+        let (dy, shift) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.shift));
+        if !shift && dy != 0.0 {
+            app.ui.project_scroll -= dy;
+        }
+    }
+    // A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
+    let sel_key = selection_seen_id();
+    let sel_now = app.session.state.project_selection.last().map(|i| i.0);
+    if ctx.data(|d| d.get_temp::>(sel_key)) != Some(sel_now) {
+        ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
+        if let Some(row) = sel_now.and_then(|s| rows.iter().position(|(id, _)| id.0 == s)) {
+            let top = row as f32 * ROW_H;
+            if top < app.ui.project_scroll {
+                app.ui.project_scroll = top;
+            } else if top + ROW_H > app.ui.project_scroll + list.height() {
+                app.ui.project_scroll = top + ROW_H - list.height();
+            }
+        }
+    }
+    // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
+    if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
+        let top = row as f32 * ROW_H;
+        // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
+        app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
+    }
+    app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
+    (app.ui.project_scroll, max_scroll)
+}
+
+/// The folder's open / close twirl (Alt / Cmd-click twirls every folder inside it too).
+fn folder_twirl(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &RowGeom, it: &effectcraft_engine::project::Item) {
+    let t = app.tokens;
+    let RowGeom { r, x0, .. } = *g;
+    let open = app.ui.project_open_folders.contains(&it.id.0);
+    let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
+    let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", it.id.0)), &t);
+    tresp.widget_info(|| {
+        let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
+        egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+    });
+    if tresp.clicked() {
+        // Alt/Cmd-click twirls every folder inside it too.
+        let mut ids = vec![it.id];
+        if ui.input(|i| i.modifiers.alt || i.modifiers.command) {
+            let mut at = 0;
+            while let Some(f) = ids.get(at).copied() {
+                ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id));
+                at += 1;
+            }
+        }
+        for f in ids {
+            if open {
+                app.ui.project_open_folders.remove(&f.0);
+            } else {
+                app.ui.project_open_folders.insert(f.0);
+            }
+        }
+    }
+    app.auto.add(&format!("project.item.{}.twirl", it.id.0), tw, &it.name);
+}
+
+/// Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
+fn proxy_indicator(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, x0 } = *g;
+    if let Some(px) = it.proxy.as_ref() {
+        let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
+        if px.enabled {
+            lp.rect_filled(pr, 1.0, t.text);
+        } else {
+            lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
+        }
+        let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", it.id.0)), Sense::click()).on_hover_text(format!(
+            "Proxy: {} ({})",
+            px.footage.path,
+            if px.enabled { "in use" } else { "off" }
+        ));
+        app.auto.add(&format!("project.item.{}.proxy", it.id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
+        presp.widget_info(|| {
+            let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
+            egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+        });
+        if presp.clicked() {
+            actions.push(("file.useProxy".into(), json!({"item": it.id.0})));
+        }
+    }
+}
+
+/// The label swatch: click for the label colour menu.
+fn label_swatch(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    label_x: f32,
+    selected: bool,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, .. } = *g;
+    let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
+    lp.rect_filled(sw, 2.0, t.label(it.label));
+    let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", it.id.0)), Sense::click());
+    app.auto.add(&format!("project.item.{}.label", it.id.0), sw, it.label.name());
+    sresp.widget_info(|| {
+        let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
+        egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+    });
+    let pop = egui::Id::unique(("plabel-pop", it.id.0));
+    if sresp.clicked() || sresp.secondary_clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
+        actions.push(("project.setLabel".into(), json!({"items": targets(app, it.id, selected), "label": label_index(l)})));
+    }
+}
+
+/// The Project panel: the selected item's thumbnail and details (with the comps that use it), the
+/// search field, sortable column headers, and the item rows (folders, comps, footage and solids)
+/// with selection, Shift/Cmd ranges, keyboard navigation, inline rename, drag-and-drop move,
+/// marquee selection and context menus. Only the rows in view are drawn. File commands are queued
+/// and run through [`crate::menus::invoke`] at the end, with failures shown in `app.ui.status`.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let p = ui.painter().clone();
+    let ctx = ui.ctx().clone();
+    // Preview header.
+    let head = Rect::from_min_size(rect.min, vec2(rect.width(), 74.0));
+    details_header(app, ui, &p, &ctx, head);
+    // Search.
+    let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0));
+    let mut q = app.ui.project_search.clone();
+    widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t);
+    app.ui.project_search = q;
+    app.auto.add("project.search", sr, "Search");
+    // Column header: Name, Label, then the visible optional columns.
+    let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
+    p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+    let cl = layout_columns(app, rect);
+    let (name_w, label_x, opt_x0, overflow, hscroll) = (cl.name_w, cl.label_x, cl.opt_x0, cl.overflow, cl.hscroll);
+    draw_header(app, ui, &p, hdr, &cl);
+    // Rows.
+    let footer_h = 28.0;
+    let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
+    let lp = p.with_clip_rect(list);
+    // The list's empty area (under the rows and the scroll bar, which take their own clicks):
+    // a click deselects, a drag draws a selection box, a double-click imports, as in After
+    // Effects (File ▸ Import ▸ File...).
+    // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
+    let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
+    app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
+    hscroll_wheel(app, ui, list, overflow);
+    let moved = keyboard_nav(app, &ctx);
+    let rows = visible_rows(app);
+    enter_renames(app, &ctx);
+    let editing: Option = ctx.data(|d| d.get_temp(edit_id()));
+    let dragging = egui::DragAndDrop::payload::(&ctx).and_then(|p| match *p {
+        DragPayload::Item(i) => Some(i),
+        _ => None,
+    });
+    let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
+    let mut drop_row: Option = None;
+    let mut actions: Vec<(String, serde_json::Value)> = vec![];
+    let (scroll, max_scroll) = update_scroll(app, ui, &ctx, list, &rows, moved);
+    let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
+    let mut y = list.min.y - (scroll - first as f32 * ROW_H);
+    let project = app.session.project.clone();
+    for (i, (id, depth)) in rows.iter().enumerate().skip(first) {
+        let Some(it) = project.item(*id) else { continue };
+        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;
+        }
+        if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) {
+            drop_row = Some(*id);
+        }
+        let selected = app.session.state.project_selection.contains(id);
+        lp.rect_filled(
+            r,
+            0.0,
+            // Flat rows like After Effects' Project panel (no stripes).
+            if selected { t.row_selected } else { t.row },
+        );
+        // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
+        // on top of it take their own clicks.
+        let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
+        app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
+        // For screen readers: a tree item named by the item and its type, with its selection,
+        // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
+        let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
+        resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
+        ctx.accesskit_node_builder(resp.id, |n| {
+            n.set_selected(selected);
+            n.set_level(depth.saturating_add(1));
+            if let Some(open) = open {
+                n.set_expanded(open);
+            }
+        });
+        if resp.has_focus() {
+            ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
+        }
+        if resp.gained_focus() {
+            app.ui.focused = crate::dock::PanelKind::Project;
+        }
+        let _ = i;
+        let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
+        let g = RowGeom { lp: &lp, list, r, x0 };
+        if it.is_folder() {
+            folder_twirl(app, ui, &g, it);
+        }
+        icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim);
+        proxy_indicator(app, ui, &g, it, &mut actions);
+        // Footage that can't be decoded (no decoder for its codec / profile): a warning marker
+        // at the end of the name cell, the reason on hover.
+        let problem = match &it.kind {
+            ItemKind::Footage(f) => app.session.footage.problem(f),
+            _ => None,
+        };
+        let name_clip =
+            Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list);
+        if let Some(why) = &problem {
+            problem_marker(app, ui, &g, name_w, it.id, why);
+        }
+        let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
+        if !cell_edit(app, ui, &mut actions, &editing, id.0, "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));
+            }
+            // Long names end in "…" before the label column instead of being cut mid-glyph.
+            widgets::text_fit(
+                &lp.with_clip_rect(name_clip),
+                pos2(x0 + 30.0, r.center().y),
+                Align2::LEFT_CENTER,
+                &it.name,
+                Tokens::ui(12.0),
+                name_clip.max.x - (x0 + 30.0) - 2.0,
+                if selected { Color32::from_gray(0x16) } else { t.text },
+            );
+        }
+        label_swatch(app, ui, &g, it, label_x, selected, &mut actions);
+        if let Some(extra) = optional_cells(app, ui, &g, it, &cl, &editing, &mut actions) {
+            resp = resp.union(extra);
+        }
+        if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
+            lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
+        }
+        // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
+        if ctx.memory(|m| m.has_focus(row_id(*id))) {
+            lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
+        }
+        // Part of the row: not a second Tab stop.
+        let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
+        app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
+        let resp = resp.union(nresp);
+        if resp.clicked() {
+            select_click(app, ui, &ctx, *id, &rows);
+        }
+        // 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() {
+            open_item(app, it, *id, &mut actions);
+        }
+        if resp.drag_started() {
+            egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
+        }
+        // Right-clicking an item selects it (After Effects): the menu's File commands act on the
+        // selection.
+        if resp.secondary_clicked() && !selected {
+            app.session.state.project_selection = vec![*id];
+        }
+        crate::widgets::context_menu(&resp, |ui| item_menu(app, ui, &ctx, it, *id, selected, &mut actions));
+    }
+    // The scroll bars, over the rows (which would take their presses otherwise).
+    if overflow > 0.0 {
+        let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
+        app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t);
+    }
+    if max_scroll > 0.0 {
+        let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
+        app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t);
+    }
+    drop_targets(app, &ctx, &lp, list, DropState { dragging, row: drop_row, hovering: hover_y.is_some() }, &mut actions);
+    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
+    footer(app, ui, &p, foot, &mut actions);
+    if empty.double_clicked() {
+        actions.push(("file.import".into(), json!({})));
+    }
+    crate::widgets::context_menu(&empty, |ui| {
+        for (key, label, command) in
+            [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")]
+        {
+            let response = ui.button(label);
+            app.auto.add(&format!("project.context.{key}"), response.rect, label);
+            if response.clicked() {
+                actions.push((command.into(), json!({})));
+                ui.close();
+            }
+        }
+    });
+    marquee_select(app, &ctx, &lp, list, &empty, &rows);
     for (id, params) in actions {
         if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
             app.ui.status = e;
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index c26bb5030..c35108937 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -91,7 +91,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
 
     // ---------------------------------------------------------------- Layer Transform
     if let Some(tr) = layer.transform().cloned() {
-        section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
+        section_header(app, &p, x0, w, y, "Layer Transform", "properties.transform");
         let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H));
         let resp = ui.interact(rr, egui::Id::unique("props-reset"), Sense::click());
         p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent });
@@ -124,7 +124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         divider(&p, x0, w, y - 4.0, &t);
         y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
         divider(&p, x0, w, y - 4.0, &t);
-        section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
+        section_header(app, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
         y += ROW_H + 10.0;
         // Both buttons grow with their (translated) labels; English keeps its 116 / 84 px.
         let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x;
@@ -180,11 +180,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 }
 
-fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
+fn section_header(app: &mut EffectcraftApp, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
     let t = app.tokens;
     p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
     let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H));
-    let _ = ui;
     app.auto.add(auto, r, title);
 }
 
@@ -286,7 +285,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
         }
         Value::Scalar(v) => {
             let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
-            let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
+            let (vr, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(vx, cy - 9.0),
+                egui::Id::unique(("props-v", uid)),
+                *v,
+                &widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix },
+                &t,
+            );
             app.auto.add(&format!("{base}.value"), vr, &pr.name);
             if let Some(nv) = nv {
                 set(actions, json!(nv));
@@ -311,8 +317,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
                 }
             }
             for d in 0..n.min(c.len()) {
-                let (vr, nv, _) =
-                    widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
+                let (vr, nv, _) = widgets::hot_number_at(
+                    ui,
+                    pos2(x, cy - 9.0),
+                    egui::Id::unique(("props-v", uid, d)),
+                    c[d],
+                    &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix },
+                    &t,
+                );
                 app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
                 if let Some(nv) = nv {
                     let mut nc = c.clone();
@@ -358,7 +370,7 @@ fn text_section(
         v["layer"] = json!(lid);
         actions.push(("layer.setText".into(), v));
     };
-    section_header(app, ui, p, x0, w, y, "Text", "properties.text");
+    section_header(app, p, x0, w, y, "Text", "properties.text");
     y += ROW_H + 4.0;
     // Font family and style.
     let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
@@ -383,7 +395,14 @@ fn text_section(
     let col2 = x0 + w / 2.0;
     let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
         p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::unique(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x + 26.0, y),
+            egui::Id::unique(("props-text", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range, decimals: decimals(v), suffix },
+            &t,
+        );
         app.auto.add(&format!("properties.text.{key}"), r, key);
         nv
     };
@@ -462,7 +481,7 @@ fn paragraph_section(
     actions: &mut Actions,
 ) -> f32 {
     let t = app.tokens;
-    section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph");
+    section_header(app, p, x0, w, y, "Paragraph", "properties.paragraph");
     y += ROW_H + 4.0;
     for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
         let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs
index c002558ab..5e39a30c2 100644
--- a/crates/ui-egui/src/panels/render_queue.rs
+++ b/crates/ui-egui/src/panels/render_queue.rs
@@ -415,20 +415,21 @@ fn pick_output(app: &EffectcraftApp, it: &RenderQueueItem) -> Option {
+    queue: &'a [RenderQueueItem],
+    progress: Option<&'a effectcraft_engine::render_queue::JobState>,
+    rendering: bool,
+    available: &'a [OutputFormat],
+}
+
+/// Comps dragged from the Project over the panel: an outline while hovering, queued on release.
+fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter, rendering: bool, actions: &mut Vec) {
     let t = app.tokens;
-    let p = ui.painter().with_clip_rect(rect);
-    let queue: Vec = app.session.project.render_queue.clone();
-    let progress = app.session.render_progress();
-    let rendering = app.session.is_rendering();
-    let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
-    let mut actions: Vec<(&'static str, Value)> = Vec::new();
-    app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
     if !rendering
         && let Some(crate::panels::DragPayload::Item(id)) = egui::DragAndDrop::payload::(ui.ctx()).as_deref()
         && app.session.project.comp(effectcraft_engine::project::ItemId(*id)).is_some()
@@ -448,14 +449,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
         }
     }
-    let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
-    let state_id = egui::Id::unique("rq-ui");
-    let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
+}
 
-    // ---------------------------------------------------------- Current Render strip
-    let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
-    p.rect_filled(top, 0.0, t.tl_ruler_bg);
-    let x0 = rect.min.x + 12.0;
+/// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify
+/// and the progress bar.
+fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) {
+    let t = app.tokens;
+    let RqView { queue, progress, rendering, available } = *rq;
+    let x0 = top.min.x + 12.0;
     p.text(pos2(x0, top.min.y + 16.0), Align2::LEFT_CENTER, tr("Current Render"), Tokens::semibold(12.5), t.text);
     let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
     let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
@@ -515,6 +516,194 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     };
     p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text);
     app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0));
+}
+
+/// The footer: the message line and the totals.
+fn footer(p: &egui::Painter, rect: Rect, rq: &RqView, t: &Tokens) {
+    let RqView { queue, progress, rendering, .. } = *rq;
+    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
+    p.rect_filled(foot, 0.0, t.tl_ruler_bg);
+    p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
+    let msg = match &progress {
+        Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
+        _ => {
+            let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
+            format!("Message: {done} of {} done", queue.len())
+        }
+    };
+    p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
+    let started = queue.iter().filter(|i| i.started.is_some()).count();
+    let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
+    p.text(
+        pos2(foot.max.x - 12.0, foot.center().y),
+        Align2::RIGHT_CENTER,
+        format!("Renders Started: {started}     Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
+        Tokens::ui(11.0),
+        t.text_dim,
+    );
+}
+
+/// An item row's context menu: Duplicate, Remove and Move Up / Down (row `k` of `len`).
+fn row_menu(ui: &mut egui::Ui, actions: &mut Vec, item_id: u64, k: usize, len: usize) {
+    if ui.button(tr("Duplicate")).clicked() {
+        actions.push(("renderQueue.duplicate", json!({"item": item_id})));
+        ui.close();
+    }
+    if ui.button(tr("Remove")).clicked() {
+        actions.push(("renderQueue.remove", json!({"item": item_id})));
+        ui.close();
+    }
+    if k > 0 && ui.button(tr("Move Up")).clicked() {
+        actions.push(("renderQueue.move", json!({"item": item_id, "to": k})));
+        ui.close();
+    }
+    if k + 1 < len && ui.button(tr("Move Down")).clicked() {
+        actions.push(("renderQueue.move", json!({"item": item_id, "to": k + 2})));
+        ui.close();
+    }
+}
+
+/// Log: Errors Only / Plus Settings / Plus Per Frame Info.
+fn log_dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, row: &ItemRow, it: &RenderQueueItem, actions: &mut Vec) {
+    let ItemRow { lp, r, n, rendering, .. } = *row;
+    let t = app.tokens;
+    let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
+    let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
+    lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
+    let lid = egui::Id::unique(("rq-log", it.id));
+    if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
+        widgets::open_popup(ui, lid);
+    }
+    let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
+    if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
+        actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
+    }
+    app.auto.add(&aid("log"), ld, it.log.label());
+}
+
+/// Where an item row's expanded line is drawn.
+#[derive(Clone, Copy)]
+struct ItemRow<'a> {
+    lp: &'a egui::Painter,
+    list: Rect,
+    r: Rect,
+    /// The item's 1-based position in the queue.
+    n: usize,
+    rendering: bool,
+}
+
+/// The expanded "Output To" line: the template menu, and the file name (click to choose it or
+/// edit the template in place).
+fn output_to(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    row: &ItemRow,
+    it: &RenderQueueItem,
+    dd_right: f32,
+    editing: &mut Option<(u64, String)>,
+    actions: &mut Vec,
+) {
+    let ItemRow { lp, list, r, n, rendering } = *row;
+    let t = app.tokens;
+    let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
+    let ox = dd_right + 14.0;
+    lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
+    let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
+    let oid = egui::Id::unique(("rq-out", it.id));
+    if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
+        widgets::open_popup(ui, oid);
+    }
+    app.auto.add(&aid("outputTo"), odd, "Output To");
+    let omenu = output_to_menu(it);
+    let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect();
+    if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
+        if omenu[i].1.get("choose").is_some() {
+            match pick_output(app, it) {
+                Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
+                Some(None) => {}
+                None => *editing = Some((it.id, it.output.output.clone())),
+            }
+        } else {
+            let mut params = omenu[i].1.clone();
+            params["item"] = json!(it.id);
+            actions.push(("renderQueue.setOutput", params));
+        }
+    }
+    let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
+    if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
+        let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
+        let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
+        if resp.lost_focus() {
+            if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
+                actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
+            }
+            *editing = None;
+        } else {
+            widgets::keep_focus(ui.ctx(), &resp, &buf);
+            *editing = Some((it.id, buf));
+        }
+    } else {
+        let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
+        let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
+        let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
+        lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
+        // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
+        // host without one, edits the name template in place.
+        let dialog = app.hooks.pick_save_file.is_some();
+        if resp.clicked() && !rendering {
+            let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
+            match picked {
+                Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
+                Some(None) => {}
+                None => *editing = Some((it.id, it.output.output.clone())),
+            }
+        }
+        let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
+        resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
+        app.auto.add(&aid("outputPath"), path_rect, &shown);
+    }
+}
+
+/// Delete removes the selected item when the panel has focus (`allowed`: not editing a path and
+/// not rendering).
+fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, selected: &mut Option, actions: &mut Vec) {
+    if app.ui.focused == crate::dock::PanelKind::RenderQueue
+        && allowed
+        && let Some(id) = *selected
+        && app.dialog.is_none()
+        && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
+        && !ui.ctx().egui_wants_keyboard_input()
+    {
+        actions.push(("renderQueue.remove", json!({"item": id})));
+        *selected = None;
+    }
+}
+
+/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
+/// Notify, plus the progress bar), the column header, the queued items with their output template,
+/// status and timings, and the footer totals. Comps dragged from the Project are queued on
+/// release. The open items and the selected item are kept in egui temp data; output-template forms
+/// open through a small form, and every change runs a `renderQueue.*` command.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let p = ui.painter().with_clip_rect(rect);
+    let queue: Vec = app.session.project.render_queue.clone();
+    let progress = app.session.render_progress();
+    let rendering = app.session.is_rendering();
+    let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
+    let mut actions: Vec = Vec::new();
+    let rq = RqView { queue: &queue, progress: progress.as_ref(), rendering, available: &available };
+    app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
+    queue_drop(app, ui, rect, &p, rendering, &mut actions);
+    let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
+    let state_id = egui::Id::unique("rq-ui");
+    let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
+
+    // ---------------------------------------------------------- Current Render strip
+    let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
+    p.rect_filled(top, 0.0, t.tl_ruler_bg);
+    current_render_strip(app, ui, &p, top, &rq, &mut actions);
 
     // ---------------------------------------------------------- column header
     let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H));
@@ -575,25 +764,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             selected = Some(it.id);
             app.ui.focused = crate::dock::PanelKind::RenderQueue;
         }
-        let item_id = it.id;
-        crate::widgets::context_menu(&row_resp, |ui| {
-            if ui.button(tr("Duplicate")).clicked() {
-                actions.push(("renderQueue.duplicate", json!({"item": item_id})));
-                ui.close();
-            }
-            if ui.button(tr("Remove")).clicked() {
-                actions.push(("renderQueue.remove", json!({"item": item_id})));
-                ui.close();
-            }
-            if k > 0 && ui.button(tr("Move Up")).clicked() {
-                actions.push(("renderQueue.move", json!({"item": item_id, "to": n - 1})));
-                ui.close();
-            }
-            if n < queue.len() && ui.button(tr("Move Down")).clicked() {
-                actions.push(("renderQueue.move", json!({"item": item_id, "to": n + 1})));
-                ui.close();
-            }
-        });
+        crate::widgets::context_menu(&row_resp, |ui| row_menu(ui, &mut actions, it.id, k, queue.len()));
         app.auto.add(&aid("row"), row, &format!("#{n}"));
         let is_open = open.contains(&it.id);
         let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
@@ -686,78 +857,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     );
                     lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
                 }
-                // Log: Errors Only / Plus Settings / Plus Per Frame Info.
-                let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
-                lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
-                let lid = egui::Id::unique(("rq-log", it.id));
-                if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
-                    widgets::open_popup(ui, lid);
-                }
-                let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
-                if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
-                    actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
-                }
-                app.auto.add(&aid("log"), ld, it.log.label());
+                log_dropdown(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, &mut actions);
                 continue;
             }
-            // Output To.
-            let ox = dd.max.x + 14.0;
-            lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
-            let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
-            let oid = egui::Id::unique(("rq-out", it.id));
-            if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
-                widgets::open_popup(ui, oid);
-            }
-            app.auto.add(&aid("outputTo"), odd, "Output To");
-            let omenu = output_to_menu(it);
-            let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect();
-            if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
-                if omenu[i].1.get("choose").is_some() {
-                    match pick_output(app, it) {
-                        Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
-                        Some(None) => {}
-                        None => editing = Some((it.id, it.output.output.clone())),
-                    }
-                } else {
-                    let mut params = omenu[i].1.clone();
-                    params["item"] = json!(it.id);
-                    actions.push(("renderQueue.setOutput", params));
-                }
-            }
-            let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
-            if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
-                let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
-                let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
-                let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
-                if resp.lost_focus() {
-                    if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
-                        actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
-                    }
-                    editing = None;
-                } else {
-                    widgets::keep_focus(ui.ctx(), &resp, &buf);
-                    editing = Some((it.id, buf));
-                }
-            } else {
-                let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
-                let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
-                let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
-                lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
-                // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
-                // host without one, edits the name template in place.
-                let dialog = app.hooks.pick_save_file.is_some();
-                if resp.clicked() && !rendering {
-                    let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
-                    match picked {
-                        Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
-                        Some(None) => {}
-                        None => editing = Some((it.id, it.output.output.clone())),
-                    }
-                }
-                let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
-                resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
-                app.auto.add(&aid("outputPath"), path_rect, &shown);
-            }
+            output_to(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, dd.max.x, &mut editing, &mut actions);
         }
     }
     ui.set_clip_rect(panel_clip);
@@ -770,40 +873,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         None => d.remove::<(u64, String)>(editing_id),
     });
 
-    // Delete removes the selected item when the panel has focus.
-    if app.ui.focused == crate::dock::PanelKind::RenderQueue
-        && editing.is_none()
-        && !rendering
-        && let Some(id) = selected
-        && app.dialog.is_none()
-        && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
-        && !ui.ctx().egui_wants_keyboard_input()
-    {
-        actions.push(("renderQueue.remove", json!({"item": id})));
-        selected = None;
-    }
+    delete_selected(app, ui, editing.is_none() && !rendering, &mut selected, &mut actions);
 
     // ---------------------------------------------------------- footer
-    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
-    p.rect_filled(foot, 0.0, t.tl_ruler_bg);
-    p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
-    let msg = match &progress {
-        Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
-        _ => {
-            let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
-            format!("Message: {done} of {} done", queue.len())
-        }
-    };
-    p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
-    let started = queue.iter().filter(|i| i.started.is_some()).count();
-    let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
-    p.text(
-        pos2(foot.max.x - 12.0, foot.center().y),
-        Align2::RIGHT_CENTER,
-        format!("Renders Started: {started}     Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
-        Tokens::ui(11.0),
-        t.text_dim,
-    );
+    footer(&p, rect, &rq, &t);
 
     open.retain(|id| queue.iter().any(|i| i.id == *id));
     ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs
index 52175899c..0d9eefe45 100644
--- a/crates/ui-egui/src/panels/roto_tool.rs
+++ b/crates/ui-egui/src/panels/roto_tool.rs
@@ -135,17 +135,15 @@ pub fn draw_overlay(
     }
 }
 
+/// The layer <-> screen mappings [`interact`] works with.
+pub struct LayerXform<'a> {
+    pub to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
+    pub to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
+}
+
 /// Pointer handling for the Roto Brush / Refine Edge tools. Returns true when it handled input.
-pub fn interact(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    painter: &egui::Painter,
-    area: Rect,
-    layer: &Layer,
-    zoom: f64,
-    to_screen: &dyn Fn([f64; 2]) -> Pos2,
-    to_layer: &dyn Fn(Pos2) -> [f64; 2],
-) -> bool {
+pub fn interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, area: Rect, layer: &Layer, zoom: f64, xf: LayerXform) -> bool {
+    let LayerXform { to_screen, to_layer } = xf;
     let ctx = ui.ctx().clone();
     let mods = ui.input(|i| i.modifiers);
     let Some(kind) = app.ui.tool.roto_kind(mods.alt) else { return false };
diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs
index c285011fe..692f48236 100644
--- a/crates/ui-egui/src/panels/settings.rs
+++ b/crates/ui-egui/src/panels/settings.rs
@@ -302,19 +302,20 @@ fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
     painter.line_segment([pos2(cti, graph.top()), pos2(cti, graph.bottom())], egui::Stroke::new(1.0, p.cti));
 }
 
+/// What [`theme_card`] shows: the setting `key`, the translated `title`, whether the family is
+/// `active`, the `cur`rent theme and the `options` (value, label) to pick from.
+struct ThemeFamily<'a> {
+    key: &'a str,
+    title: &'a str,
+    active: bool,
+    cur: &'a str,
+    options: &'a [(&'a str, &'a str)],
+}
+
 /// One theme family's card: its (translated) name, Active when the appearance shows it now, a preview of the
 /// chosen theme and a radio button per theme.
-fn theme_card(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    t: &Tokens,
-    key: &str,
-    title: &str,
-    active: bool,
-    cur: &str,
-    options: &[(&str, &str)],
-    acts: &mut Vec,
-) {
+fn theme_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, fam: ThemeFamily, acts: &mut Vec) {
+    let ThemeFamily { key, title, active, cur, options } = fam;
     egui::Frame::new()
         .fill(t.field_bg)
         .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border }))
@@ -396,8 +397,20 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T
     let light_active = mode == "light" || (mode == "auto" && system_light == Some(true));
     ui.columns(2, |cols| {
         let [l, d] = cols else { return };
-        theme_card(app, l, t, "appearance.lightTheme", tr("Light Theme"), light_active, &light, LIGHT_THEMES, acts);
-        theme_card(app, d, t, "appearance.darkTheme", tr("Dark Theme"), !light_active, &dark, DARK_THEMES, acts);
+        theme_card(
+            app,
+            l,
+            t,
+            ThemeFamily { key: "appearance.lightTheme", title: tr("Light Theme"), active: light_active, cur: &light, options: LIGHT_THEMES },
+            acts,
+        );
+        theme_card(
+            app,
+            d,
+            t,
+            ThemeFamily { key: "appearance.darkTheme", title: tr("Dark Theme"), active: !light_active, cur: &dark, options: DARK_THEMES },
+            acts,
+        );
     });
     ui.add_space(6.0);
 }
diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs
index 2a3fb5063..8a8e604e2 100644
--- a/crates/ui-egui/src/panels/text_panels.rs
+++ b/crates/ui-egui/src/panels/text_panels.rs
@@ -64,7 +64,8 @@ pub fn text_target(app: &EffectcraftApp) -> Option {
 /// A small toggle button at `r` showing `label` (semibold 13 when `bold`, else 12), filled with
 /// the accent while `on`; returns whether it was clicked. It only reports the click: the caller
 /// decides the new state.
-fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
+fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, look: (&str, bool, bool)) -> bool {
+    let (label, on, bold) = look;
     let t = app.tokens;
     let resp = ui.interact(r, egui::Id::unique(("text-toggle", id)), Sense::click());
     p.rect_filled(
@@ -84,6 +85,103 @@ fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rec
     resp.clicked()
 }
 
+/// Position of the numeric grid (`x0`, top `y`, width `w`) and whether edits are collected.
+#[derive(Clone, Copy)]
+struct Grid {
+    x0: f32,
+    y: f32,
+    w: f32,
+    enabled: bool,
+}
+
+/// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 left).
+fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
+        ("size", "T", doc.size, (1.0, 2000.0), " px"),
+        ("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
+        ("", "", 0.0, (0.0, 0.0), ""),
+        ("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
+        ("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
+        ("", "", 0.0, (0.0, 0.0), ""),
+        ("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
+        ("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
+        ("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
+        ("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
+    ];
+    for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
+        if key.is_empty() {
+            continue;
+        }
+        let col = i % 2;
+        let row = i / 2;
+        let fx = x0 + col as f32 * (w / 2.0);
+        let fy = y + row as f32 * 28.0;
+        p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(fx + 26.0, fy),
+            egui::Id::unique(("char", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
+            &t,
+        );
+        app.auto.add(&format!("character.{key}"), r, key);
+        if let Some(nv) = nv
+            && enabled
+        {
+            actions.push(json!({key: nv, "merge": format!("char-{key}")}));
+        }
+    }
+}
+
+/// Kerning: Metrics / Optical / a manual value (grid row 1, left).
+fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fy = y + 28.0;
+    p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
+    let label = match doc.kerning {
+        Kerning::Metrics => "Metrics".to_string(),
+        Kerning::Optical => "Optical".to_string(),
+        Kerning::Manual(v) => format!("{v:.0}"),
+    };
+    let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
+    let kpop = egui::Id::unique("char-kerning-pop");
+    if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
+        widgets::open_popup(ui, kpop);
+    }
+    app.auto.add("character.kerning", kr, "Kerning");
+    let opts: Vec = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
+    if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
+        let v = match i {
+            0 => json!("metrics"),
+            1 => json!("optical"),
+            _ => json!(opts[i].parse::().unwrap_or(0.0)),
+        };
+        actions.push(json!({"kerning": v}));
+    }
+}
+
+/// Stroke Over Fill / Fill Over Stroke (grid row 2, right).
+fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fx = x0 + w / 2.0;
+    let fy = y + 2.0 * 28.0;
+    let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
+    let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
+    let pop = egui::Id::unique("char-stroke-order-pop");
+    if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
+        widgets::open_popup(ui, pop);
+    }
+    app.auto.add("character.strokeOverFill", dr, "Stroke order");
+    if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
+        actions.push(json!({"strokeOverFill": i == 1}));
+    }
+}
+
 /// The Character panel for the text being edited (or the selected text layer): font family and
 /// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
 /// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
@@ -122,76 +220,10 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(json!({"style": st}));
     }
     y += 34.0;
-    // Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 right).
-    let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
-        ("size", "T", doc.size, (1.0, 2000.0), " px"),
-        ("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
-        ("", "", 0.0, (0.0, 0.0), ""),
-        ("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
-        ("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
-        ("", "", 0.0, (0.0, 0.0), ""),
-        ("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
-        ("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
-        ("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
-        ("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
-    ];
-    for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
-        if key.is_empty() {
-            continue;
-        }
-        let col = i % 2;
-        let row = i / 2;
-        let fx = x0 + col as f32 * (w / 2.0);
-        let fy = y + row as f32 * 28.0;
-        p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, 0.5, range, 0, suffix, &t);
-        app.auto.add(&format!("character.{key}"), r, key);
-        if let Some(nv) = nv
-            && enabled
-        {
-            actions.push(json!({key: nv, "merge": format!("char-{key}")}));
-        }
-    }
-    // Kerning: Metrics / Optical / a manual value (row 1, left).
-    {
-        let fy = y + 28.0;
-        p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
-        let label = match doc.kerning {
-            Kerning::Metrics => "Metrics".to_string(),
-            Kerning::Optical => "Optical".to_string(),
-            Kerning::Manual(v) => format!("{v:.0}"),
-        };
-        let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
-        let kpop = egui::Id::unique("char-kerning-pop");
-        if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
-            widgets::open_popup(ui, kpop);
-        }
-        app.auto.add("character.kerning", kr, "Kerning");
-        let opts: Vec = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
-        if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
-            let v = match i {
-                0 => json!("metrics"),
-                1 => json!("optical"),
-                _ => json!(opts[i].parse::().unwrap_or(0.0)),
-            };
-            actions.push(json!({"kerning": v}));
-        }
-    }
-    // Stroke Over Fill / Fill Over Stroke (row 2, right).
-    {
-        let fx = x0 + w / 2.0;
-        let fy = y + 2.0 * 28.0;
-        let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
-        let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
-        let pop = egui::Id::unique("char-stroke-order-pop");
-        if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
-            widgets::open_popup(ui, pop);
-        }
-        app.auto.add("character.strokeOverFill", dr, "Stroke order");
-        if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
-            actions.push(json!({"strokeOverFill": i == 1}));
-        }
-    }
+    let grid = Grid { x0, y, w, enabled };
+    numeric_fields(app, ui, &p, grid, &doc, &mut actions);
+    kerning_field(app, ui, &p, grid, &doc, &mut actions);
+    stroke_order_field(app, ui, grid, &doc, &mut actions);
     y += 5.0 * 28.0 + 8.0;
     // Fill / stroke swatches with their enable boxes.
     for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
@@ -242,7 +274,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let bw = ((w - 5.0 * 4.0) / 6.0).clamp(22.0, 30.0);
     for (i, (key, label, on, bold)) in toggles.into_iter().enumerate() {
         let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y), vec2(bw, 24.0));
-        if toggle(app, ui, &p, r, &format!("character.{key}"), label, on, bold) && enabled {
+        if toggle(app, ui, &p, r, &format!("character.{key}"), (label, on, bold)) && enabled {
             actions.push(json!({key: !on}));
         }
     }
@@ -296,8 +328,14 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let tag = a.tag.trim_end().to_string();
             let v = doc.variations.iter().chain(&instance).find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
             p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
-            let (r, nv, _) =
-                widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::unique(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
+            let (r, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(fx + 34.0, fy),
+                egui::Id::unique(("char-axis", &tag)),
+                v as f64,
+                &widgets::HotSpec { speed: 1.0, range: (a.min as f64, a.max as f64), decimals: 0, suffix: "" },
+                &t,
+            );
             app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
             if let Some(nv) = nv
                 && enabled
@@ -517,7 +555,14 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let fx = x0 + (i % 2) as f32 * (w / 2.0);
         let fy = y + (i / 2) as f32 * 28.0;
         p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(fx + 26.0, fy),
+            egui::Id::unique(("para-f", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range: (-5000.0, 5000.0), decimals: 0, suffix: " px" },
+            &t,
+        );
         app.auto.add(&format!("paragraph.{key}"), r, key);
         if let Some(nv) = nv
             && enabled
diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs
index ea18c7f1e..a45d8e708 100644
--- a/crates/ui-egui/src/panels/timeline.rs
+++ b/crates/ui-egui/src/panels/timeline.rs
@@ -15,6 +15,7 @@ use effectcraft_engine::render::EvalCtx;
 use effectcraft_engine::time::Tick;
 use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
 use serde_json::json;
+use std::sync::Arc;
 
 use crate::i18n::tr;
 use crate::icons::{self, Icon};
@@ -169,19 +170,24 @@ fn pick_whip_id() -> egui::Id {
     egui::Id::unique("tl-pickwhip")
 }
 
+/// The automation id, label and hover hint of a pick whip.
+struct WhipText<'a> {
+    auto: &'a str,
+    label: &'a str,
+    hint: &'a str,
+}
+
 /// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for
 /// the layer's own), drawn and dropped at the end of [`show`].
-#[allow(clippy::too_many_arguments)]
 fn pick_whip(
     app: &mut EffectcraftApp,
     ui: &egui::Ui,
     p: &egui::Painter,
     rect: Rect,
     (kind, layer, prop): (u8, u64, u64),
-    auto: &str,
-    label: &str,
-    hint: &str,
+    text: WhipText<'_>,
 ) -> egui::Response {
+    let WhipText { auto, label, hint } = text;
     let resp = ui.interact(rect, egui::Id::unique(("pick-whip", kind, layer, prop)), Sense::drag()).on_hover_text(hint);
     icons::paint(p, rect.shrink(1.0), Icon::PickWhip, if resp.hovered() || resp.dragged() { app.tokens.text } else { app.tokens.text_dim });
     app.auto.add(auto, rect, label);
@@ -1415,12 +1421,9 @@ fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec {
     edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect()
 }
 
-/// A layer's switch (A/V Features and Switches columns) in `row`, with the
-/// [`switch_gesture`].
-#[allow(clippy::too_many_arguments)]
-fn layer_switch(
-    ui: &mut egui::Ui,
-    t: &Tokens,
+/// Where and what a layer switch is: its cell, icon, state and the switch it sets.
+#[derive(Clone, Copy)]
+struct SwitchSpec {
     row: Rect,
     br: Rect,
     icon: Icon,
@@ -1429,11 +1432,20 @@ fn layer_switch(
     layer: u64,
     name: &'static str,
     others: bool,
+}
+
+/// A layer's switch (A/V Features and Switches columns) in `row`, with the
+/// [`switch_gesture`].
+fn layer_switch(
+    ui: &mut egui::Ui,
+    t: &Tokens,
+    spec: &SwitchSpec,
     comp: &Comp,
     project: &Project,
     selected: &[LayerId],
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let SwitchSpec { row, br, icon, on, id, layer, name, others } = *spec;
     let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
     // Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked),
     // and come back on the next Alt-click. With several layers selected, the whole selection is
@@ -1516,54 +1528,108 @@ fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: L
             .is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }))
 }
 
-/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
-/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
-/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
-/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
-/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
-/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
-/// open rows and the drags in progress) lives in
-/// `app.ui.timeline` and egui's temp data.
-pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+/// The frame's layout and state shared by the Timeline's drawing steps.
+struct Tl {
+    t: Tokens,
+    cid: ItemId,
+    time: Tick,
+    fr: effectcraft_engine::time::FrameRate,
+    rect: Rect,
+    header_h: f32,
+    colhdr_h: f32,
+    footer_h: f32,
+    vis: Vis,
+    left_w: f32,
+    overflow: f32,
+    oscroll: f32,
+    cw: Cols,
+    graph_x0: f32,
+    graph_x1: f32,
+    fit_pps: f64,
+    pps: f64,
+    scroll_to: Option,
+    tm: TMap,
+    top: f32,
+    rows_rect: Rect,
+    ruler: Rect,
+    bin: Rect,
+    tc_rect: Rect,
+    wa_row: Rect,
+    fd: f64,
+    rh: f32,
+    max_scroll: f32,
+    ctx: egui::Context,
+    comp: Arc,
+    p: egui::Painter,
+    rows: Arc>,
+    actions: Vec<(String, serde_json::Value)>,
+}
+
+/// [`Tl`] plus what the layer rows and the interactions after them share.
+struct Rw {
+    tl: Tl,
+    lp: egui::Painter,
+    gp: egui::Painter,
+    graph_on: bool,
+    full_clip: Rect,
+    left_clip: Rect,
+    right_clip: Rect,
+    empty_rect: Rect,
+    outline_rect: Rect,
+    empty: egui::Response,
+    outline_empty: egui::Response,
+    selected: Vec,
+    sel_keys: Vec,
+    ui_actions: Vec,
+    hit_rows: Vec<(Rect, Row)>,
+    dim_hits: Vec<(u64, Vec)>,
+}
+
+/// One visible row: what the per-kind drawing steps need to know about it.
+struct RowCtx<'a> {
+    row: &'a Row,
+    layer: &'a Layer,
+    r: Rect,
+    left: Rect,
+    cy: f32,
+    is_sel: bool,
+    ri: usize,
+    indent: f32,
+}
+
+/// The responses of a layer bar's body and its two trim edges.
+struct BarResps {
+    body: egui::Response,
+    lin: egui::Response,
+    lout: egui::Response,
+}
+
+/// An expression row's property, expression and editor ids.
+#[derive(Clone, Copy)]
+struct ExprRow<'a> {
+    prop: &'a Property,
+    ex: &'a effectcraft_engine::project::Expression,
+    buf_id: egui::Id,
+    edit_id: egui::Id,
+}
+
+/// A keyframe marker's hit result.
+struct KeyHit<'a> {
+    k: &'a effectcraft_engine::keyframe::Keyframe,
+    ct: Tick,
+    ks: bool,
+    kresp: egui::Response,
+    mods: egui::Modifiers,
+    this_key: serde_json::Value,
+}
+
+/// Computes this frame's layout: zoom, scroll and the panel's regions.
+fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp: &Arc) -> Tl {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
-    let Some(cid) = app.session.active_comp_id() else {
-        ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
-        // Dropping footage into an empty Timeline should create its first composition,
-        // just like dropping onto the Project panel's New Composition button (#649).
-        if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref()
-            && ui.rect_contains_pointer(rect)
-        {
-            let items: Vec = crate::panels::project_drop_items(app, *dragged)
-                .into_iter()
-                .map(ItemId)
-                .filter(|id| {
-                    app.session.project.item(*id).is_some_and(|item| match &item.kind {
-                        effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
-                        effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
-                        _ => false,
-                    })
-                })
-                .collect();
-            if !items.is_empty() {
-                ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
-                app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
-                if ctx.input(|i| i.pointer.any_released()) {
-                    app.session.state.project_selection = items;
-                    if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
-                        app.ui.status = e;
-                    }
-                    egui::DragAndDrop::clear_payload(&ctx);
-                }
-            }
-        }
-        return;
-    };
-    let Some(comp) = app.session.project.comp_arc(cid) else { return };
     let p = ui.painter().clone();
     let time = app.session.time();
     let fr = comp.frame_rate;
-
     // Geometry.
     let header_h = 48.0;
     let ruler_h = 34.0;
@@ -1581,7 +1647,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let graph_x0 = rect.min.x + left_w + 1.0;
     let graph_x1 = rect.max.x - GUTTER_W;
     ctx.data_mut(|d| d.insert_temp(egui::Id::unique("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
-    let fit_pps = fit_pps(graph_x1 - graph_x0, &comp);
+    let fit_pps = fit_pps(graph_x1 - graph_x0, comp);
     let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
     if app.ui.timeline.pps.is_none() {
         app.ui.timeline.start = 0.0;
@@ -1605,17 +1671,434 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let top = rect.min.y;
     let rows_top = top + header_h + colhdr_h;
     let rows_rect = Rect::from_min_max(pos2(rect.min.x, rows_top), pos2(rect.max.x, rect.max.y - footer_h));
-    let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), pos2(rect.max.x, rect.max.y - footer_h));
     p.rect_filled(Rect::from_min_max(pos2(graph_x0, top), rect.max), 0.0, t.tl_bg);
     p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg));
 
-    let mut actions: Vec<(String, serde_json::Value)> = Vec::new();
-    if !ui.input(|i| i.pointer.primary_down()) {
-        ctx.data_mut(|d| d.remove::(switch_drag_id()));
-    }
-    // ---- header (left): time display, search, switches.
-    let tc = crate::panels::timecode(&app.session, &comp, time);
+    let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
+    // The composition marker bin, at the right end of the ruler above the layers' scroll bar.
+    let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
     let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
+    let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
+    let fd = comp.frame_duration().seconds();
+    Tl {
+        t,
+        cid,
+        time,
+        fr,
+        rect,
+        header_h,
+        colhdr_h,
+        footer_h,
+        vis,
+        left_w,
+        overflow,
+        oscroll,
+        cw,
+        graph_x0,
+        graph_x1,
+        fit_pps,
+        pps,
+        scroll_to,
+        tm,
+        top,
+        rows_rect,
+        ruler,
+        bin,
+        tc_rect,
+        wa_row,
+        fd,
+        rh: t.row_h,
+        max_scroll: 0.0,
+        ctx,
+        comp: comp.clone(),
+        p,
+        rows: Arc::new(Vec::new()),
+        actions: Vec::new(),
+    }
+}
+
+/// Without an active composition: a hint, and dropping footage creates the first composition.
+fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
+    // Dropping footage into an empty Timeline should create its first composition,
+    // just like dropping onto the Project panel's New Composition button (#649).
+    if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref()
+        && ui.rect_contains_pointer(rect)
+    {
+        let items: Vec = crate::panels::project_drop_items(app, *dragged)
+            .into_iter()
+            .map(ItemId)
+            .filter(|id| {
+                app.session.project.item(*id).is_some_and(|item| match &item.kind {
+                    effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
+                    effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
+                    _ => false,
+                })
+            })
+            .collect();
+        if !items.is_empty() {
+            ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
+            app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
+            if ctx.input(|i| i.pointer.any_released()) {
+                app.session.state.project_selection = items;
+                if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
+                    app.ui.status = e;
+                }
+                egui::DragAndDrop::clear_payload(&ctx);
+            }
+        }
+    }
+}
+
+/// Starts the rows pass: the clipped painters and the background responses.
+fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw {
+    let rows_rect = tl.rows_rect;
+    let graph_x0 = tl.graph_x0;
+    let rect = tl.rect;
+    let lp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
+    let gp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
+    let graph_on = app.ui.timeline.graph_editor;
+    super::graph::track_open(ui.ctx(), graph_on);
+    let full_clip = ui.clip_rect();
+    let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
+    let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
+    // Registered before the rows so keys, bars and editors on top of it get the pointer first.
+    let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
+    let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
+    let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
+    let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
+    app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
+    Rw {
+        lp,
+        gp,
+        graph_on,
+        full_clip,
+        left_clip,
+        right_clip,
+        empty_rect,
+        outline_rect,
+        empty,
+        outline_empty,
+        selected: app.session.state.selected_layers.clone(),
+        sel_keys: app.session.state.selected_keys.clone(),
+        ui_actions: Vec::new(),
+        hit_rows: Vec::new(),
+        dim_hits: Vec::new(),
+        tl,
+    }
+}
+
+/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
+/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
+/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
+/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
+/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
+/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
+/// open rows and the drags in progress) lives in
+/// `app.ui.timeline` and egui's temp data.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let Some(cid) = app.session.active_comp_id() else {
+        empty_timeline(app, ui, rect);
+        return;
+    };
+    let Some(comp) = app.session.project.comp_arc(cid) else { return };
+    let mut tl = layout(app, ui, rect, cid, &comp);
+    let g = &mut tl;
+    if !ui.input(|i| i.pointer.primary_down()) {
+        g.ctx.data_mut(|d| d.remove::(switch_drag_id()));
+    }
+    header_timecode(app, ui, g);
+    header_search_and_switches(app, ui, g);
+    time_navigator(app, ui, g);
+    ruler_background(app, g);
+    work_area_bar(app, ui, g);
+    cache_bars(app, g);
+    ruler_ticks(app, g);
+    ruler_scrub(app, ui, g);
+    column_header_icons(app, g);
+    column_header_interact(app, ui, g);
+    let rows = Arc::new(with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &g.ctx));
+    let total_h: f32 = rows.iter().map(|r| row_height(r, g.rh)).sum();
+    g.max_scroll = (total_h - g.rows_rect.height() + g.rh).max(0.0);
+    g.rows = rows;
+    reveal_scroll(app, g);
+    wheel_zoom_scroll(app, ui, g);
+    pick_whip_edge_scroll(app, g);
+    let snap_project = app.session.project.clone();
+    let snap_expr = app.session.expr.clone();
+    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None };
+    let mut rw = begin_rows(app, ui, tl);
+    let g = &mut rw;
+    draw_rows(app, ui, g, &ectx);
+    ui.set_clip_rect(g.full_clip);
+    background_menus(app, ui, g);
+    outline_marquee(ui, g);
+    pick_whip_drag(g);
+    alt_scale_and_graph(app, ui, g, &ectx);
+    graph_empty_area(ui, g);
+    layer_reorder_drag(ui, g);
+    quick_offset_follow(g);
+    key_drag_follow(app, g);
+    key_box_select(app, ui, g);
+    current_time_indicator(app, g);
+    let footer = footer_bar(app, ui, g);
+    footer_zoom(app, ui, g, footer);
+    comp_markers_ui(app, ui, g);
+    hand_tool_pan(app, ui, g);
+    drop_targets(app, ui, g);
+    let Rw { tl, ui_actions, .. } = rw;
+    apply_ui_acts(app, &tl.comp, ui_actions);
+    apply_actions(app, ui, &tl.ctx, tl.actions);
+}
+
+/// Applies the UI-only changes the frame collected (twirls, time, ending an undo merge).
+fn apply_ui_acts(app: &mut EffectcraftApp, comp: &Comp, ui_actions: Vec) {
+    for a in ui_actions {
+        match a {
+            UiAct::ToggleLayer(id, all) => {
+                let tl = &mut app.ui.timeline;
+                // Twirled by hand, the layer shows its whole tree again.
+                tl.reveal.clear();
+                tl.layer_reveal.remove(&id);
+                tl.full_groups.remove(&id);
+                let open = !tl.open_layers.contains(&id);
+                if open {
+                    tl.open_layers.insert(id);
+                } else {
+                    tl.open_layers.remove(&id);
+                }
+                if all && let Some(l) = comp.layer(LayerId(id)) {
+                    let mut uids = vec![];
+                    walk_groups(&l.props, &mut uids);
+                    for u in uids {
+                        if open {
+                            tl.open_groups.insert(u);
+                        } else {
+                            tl.open_groups.remove(&u);
+                        }
+                    }
+                }
+            }
+            UiAct::ToggleGroup(layer, uid, all) => {
+                let tl = &mut app.ui.timeline;
+                tl.toggle_group(layer, uid);
+                let open = tl.open_groups.contains(&uid);
+                if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) {
+                    let mut uids = vec![];
+                    walk_groups(g, &mut uids);
+                    for u in uids {
+                        if open {
+                            tl.open_groups.insert(u);
+                        } else {
+                            tl.open_groups.remove(&u);
+                        }
+                    }
+                }
+            }
+            UiAct::SetTime(tt) => app.session.set_time(tt),
+            UiAct::EndMerge => app.session.history.merge_key = None,
+            UiAct::ChangeRenderer => {
+                if super::dialogs::open_comp_settings(app).is_ok() {
+                    app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
+                }
+            }
+        }
+    }
+}
+
+/// Runs the commands the frame collected through [`crate::menus::invoke`].
+fn apply_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, actions: Vec<(String, serde_json::Value)>) {
+    if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
+        app.ui.viewer.property_interacting = true;
+        ctx.request_repaint();
+    }
+    for (id, params) in actions {
+        if id == "__endMerge" {
+            app.session.history.merge_key = None;
+            continue;
+        }
+        if let Err(e) = crate::menus::invoke(app, ctx, &id, params) {
+            app.ui.status = e;
+        }
+    }
+}
+
+/// Draws the visible rows, top to bottom, each by its kind.
+fn draw_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
+    let comp = g.tl.comp.clone();
+    let rows = g.tl.rows.clone();
+    let (rect, rows_rect, rh, cw_name) = (g.tl.rect, g.tl.rows_rect, g.tl.rh, g.tl.cw.name);
+    let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
+    // Rows stripe by their index among the rows that are not expression editors, so adding or
+    // removing an expression leaves every other row's stripe alone; an editor takes the index
+    // (and so the stripe) of its property's row, the one just above.
+    let mut striped = 0usize;
+    for row in rows.iter() {
+        let ri = if matches!(row.kind, RowKind::Expr { .. }) {
+            striped.saturating_sub(1)
+        } else {
+            striped += 1;
+            striped - 1
+        };
+        // Outline widgets never spill into the time graph (narrow timelines).
+        ui.set_clip_rect(g.left_clip);
+        let hh = row_height(row, rh);
+        let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
+        y += hh;
+        if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
+            continue;
+        }
+        g.hit_rows.push((r, row.clone()));
+        let Some(layer) = comp.layer(row.layer) else { continue };
+        let rc = RowCtx {
+            row,
+            layer,
+            r,
+            left: Rect::from_min_max(r.min, pos2(g.tl.graph_x0 - 1.0, r.max.y)),
+            cy: r.min.y + rh / 2.0,
+            is_sel: g.selected.contains(&layer.id),
+            ri,
+            indent: cw_name + 6.0 + 14.0 * row.depth as f32,
+        };
+        match &row.kind {
+            RowKind::Layer => layer_row(app, ui, g, &rc),
+            RowKind::Group { .. } => group_row(app, ui, g, &rc),
+            RowKind::Waveform { .. } => waveform_row(app, g, &rc),
+            RowKind::Expr { .. } => expr_row(app, ui, g, &rc),
+            RowKind::Prop { .. } => prop_row(app, ui, g, &rc, ectx),
+        }
+    }
+}
+
+/// A layer row: switches, label, name, cells and the layer bar.
+fn layer_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    layer_row_background(g, rc);
+    layer_av_switches(app, ui, g, rc);
+    layer_label_swatch(app, ui, g, rc);
+    layer_number_and_cells(app, ui, g, rc);
+    let (row_resp, name_x) = layer_twirl_and_icon(app, ui, g, rc);
+    layer_name(app, ui, g, rc, name_x);
+    layer_row_actions(app, ui, g, rc, &row_resp);
+    layer_switches(app, ui, g, rc);
+    if app.ui.timeline.show_modes && rc.layer.source.is_av() {
+        layer_mode_cell(app, ui, g, rc);
+        layer_matte_cell(app, ui, g, rc);
+    }
+    if g.tl.vis.parent {
+        layer_parent_cell(app, ui, g, rc);
+    }
+    ui.set_clip_rect(g.right_clip);
+    if !g.graph_on {
+        layer_bar(app, ui, g, rc);
+    }
+}
+
+/// The layer's duration bar: source extent, move and trim, markers.
+fn layer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let (bar, lc) = layer_bar_paint(g, rc);
+    layer_source_extent(app, ui, g, rc, bar, lc);
+    let bars = layer_bar_hits(app, ui, g, rc, bar);
+    let quick = layer_quick_offset_begin(ui, g, rc, &bars.body);
+    layer_bar_drag(g, rc, &bars, quick);
+    layer_bar_markers(app, ui, g, rc);
+}
+
+/// A group row (effect, mask, shape group, layer style).
+fn group_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let shown = group_background(app, g, rc);
+    group_switches(app, ui, g, rc);
+    group_name(app, ui, g, rc, &shown);
+    group_popups(app, ui, g, rc);
+    group_mask_mode(ui, g, rc);
+    group_row_click(app, ui, g, rc);
+}
+
+/// An expression row: its controls and the editor.
+fn expr_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    let Some(prop) = rc.layer.props.find(*uid) else { return };
+    let Some(ex) = prop.expr.as_ref() else { return };
+    let st = ExprRow { prop, ex, buf_id: expr_buf_id(*uid), edit_id: egui::Id::unique(("expr-edit", uid)) };
+    expr_controls(app, ui, g, rc, &st);
+    expr_language_menu(app, ui, g, rc, &st);
+    expr_editor_cell(app, ui, g, rc, &st);
+}
+
+/// A property row: keyframe navigator, stopwatch, name, value editors and the keyframes.
+fn prop_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>) {
+    let RowKind::Prop { uid } = &rc.row.kind else { return };
+    let Some(prop) = rc.layer.props.find(*uid) else { return };
+    prop_background(app, g, rc, prop);
+    prop_key_navigator(app, ui, g, rc, prop);
+    prop_stopwatch(app, ui, g, rc, prop);
+    let name_x = prop_name(app, ui, g, rc, prop);
+    prop_value_cell(app, ui, g, rc, ectx, prop, name_x);
+    prop_expression_hint(app, ui, g, rc, prop);
+    if !g.graph_on {
+        for k in &prop.keys {
+            key_marker(app, ui, g, rc, prop, k);
+        }
+    }
+}
+
+/// One keyframe marker: its look, selection, menu and drag.
+fn key_marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, k: &effectcraft_engine::keyframe::Keyframe) {
+    let RowKind::Prop { uid } = &rc.row.kind else { return };
+    let ct = rc.layer.comp_time(k.time);
+    let x = g.tl.tm.x(ct.seconds());
+    if x < g.tl.graph_x0 - 8.0 || x > g.tl.rect.max.x + 8.0 {
+        return;
+    }
+    let kref = effectcraft_engine::KeyRef { layer: rc.layer.id, prop: *uid, time: k.time };
+    let ks = g.sel_keys.contains(&kref);
+    let kr = key_paint(g, rc, k, x, ks);
+    let kresp = key_hit(app, ui, g, rc, prop, k, kr);
+    let mods = ui.input(|i| i.modifiers);
+    let this_key = json!({"keys": [{"layer": rc.layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
+    let kh = KeyHit { k, ct, ks, kresp, mods, this_key };
+    key_select(g, &kh);
+    key_double_click(g, rc, prop, &kh);
+    key_context_menu(app, g, rc, prop, &kh);
+    key_drag_begin(app, g, &kh);
+}
+
+/// Drop targets: effects and presets on a layer, footage and files into the stack.
+fn drop_targets(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let ctx = g.tl.ctx.clone();
+    let rows_rect = g.tl.rows_rect;
+    if let Some(payload) = egui::DragAndDrop::payload::(&ctx)
+        && !matches!(*payload, crate::panels::DragPayload::Property { .. })
+        && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
+    {
+        let released = ctx.input(|i| i.pointer.any_released());
+        // Alt/Option-drag from the Project panel replaces the source of the layer under the
+        // pointer (After Effects). The shortcut replaces every selected layer instead.
+        let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_));
+        if alt_item {
+            drop_replace_source(app, g, &payload, ptr, released);
+        } else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
+            drop_on_layer(app, g, &payload, ptr, released);
+        } else {
+            drop_insert_layers(app, g, &payload, ptr, released);
+        }
+        if released {
+            egui::DragAndDrop::clear_payload(&ctx);
+        }
+    }
+}
+
+/// The header's time display: click to switch style, drag to scrub, double-click to type a time.
+fn header_timecode(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let time = g.time;
+    let fr = g.fr;
+    let tc_rect = g.tc_rect;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
+    let tc = crate::panels::timecode(&app.session, &comp, time);
     let tc_resp = ui.interact(tc_rect, egui::Id::unique("tl-timecode"), Sense::click_and_drag());
     p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
     // Under it the other style (frames under timecode, timecode under frames) and the rate.
@@ -1657,6 +2140,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-tc-edit"), buf));
         }
     }
+}
+
+/// The header's search field and the composition switches.
+fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let rect = g.rect;
+    let left_w = g.left_w;
+    let top = g.top;
+    let tc_rect = g.tc_rect;
+    let comp = g.comp.clone();
     let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0));
     let mut search = app.ui.timeline.search.clone();
     widgets::search_field(ui, sr, &mut search, "Search", &t);
@@ -1685,11 +2178,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         bx -= 28.0;
     }
+}
 
-    // ---- ruler (right).
-    let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
-    // The composition marker bin, at the right end of the ruler above the layers' scroll bar.
-    let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
+/// The time navigator: the visible span as a bar with end handles.
+fn time_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let fit_pps = g.fit_pps;
+    let pps = g.pps;
+    let tm = g.tm;
+    let top = g.top;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
     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.
@@ -1730,12 +2232,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             set_zoom(&mut app.ui.timeline, &comp, span_px / span.max(min_span), fit_pps, keep_t, keep_px);
         }
     }
+}
+
+fn ruler_background(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let p = g.p.clone();
     p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
     app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps));
+}
+
+/// The work area bar beside the column headers, with its begin / end handles.
+fn work_area_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
+    let t = g.t;
+    let fr = g.fr;
+    let pps = g.pps;
+    let tm = g.tm;
+    let wa_row = g.wa_row;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
+    let actions = &mut g.actions;
     // Work area bar: below the ruler, beside the column headers (After Effects' layout), with
     // blue begin / end handles; the cache bar runs under it. Dragging the bar moves the work
     // area, the handles move its ends; Shift snaps (#252).
-    let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds());
     let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0));
     p.with_clip_rect(wa_row).rect_filled(wa, 0.0, t.tl_work_area_bar);
@@ -1791,10 +2312,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"}));
         }
     }
+}
+
+/// The RAM (green) and disk (blue) cache bars under the work area.
+fn cache_bars(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let cid = g.cid;
+    let tm = g.tm;
+    let wa_row = g.wa_row;
+    let fd = g.fd;
+    let comp = g.comp.clone();
+    let p = g.p.clone();
     // Cache bar (green: cached frames of what the viewer shows: its resolution, view and options).
     let series = app.shown_series(cid);
     let cached = app.frames.cached_frames(&series);
-    let fd = comp.frame_duration().seconds();
     let cy0 = wa_row.max.y - 4.0;
     let mut run: Option<(i64, i64)> = None;
     let draw_run = |a: i64, b: i64| {
@@ -1843,6 +2374,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             draw_blue(a, b);
         }
     }
+}
+
+/// The ruler's tick marks and labels.
+fn ruler_ticks(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let fr = g.fr;
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let pps = g.pps;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let bin = g.bin;
+    let fd = g.fd;
+    let p = g.p.clone();
     // Ticks + labels.
     // Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels).
     let fps_i = fr.as_f64().round().max(1.0) as i64;
@@ -1871,6 +2416,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         s += secs_per_label;
     }
+}
+
+/// Scrubbing in the ruler: the current time follows the pointer.
+fn ruler_scrub(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let pps = g.pps;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let bin = g.bin;
+    let comp = g.comp.clone();
     // Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down
     // (After Effects), then follows it.
     let rresp = ui.interact(
@@ -1929,8 +2485,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             app.scrub_audio(cid, t, now);
         }
     }
+}
 
-    // ---- column headers (right-click: show/hide columns; click the name header: Source/Layer Name).
+/// The column headers' background, icons and titles.
+fn column_header_icons(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let rect = g.rect;
+    let header_h = g.header_h;
+    let colhdr_h = g.colhdr_h;
+    let vis = g.vis;
+    let graph_x0 = g.graph_x0;
+    let top = g.top;
+    let p = g.p.clone();
+    let cw = &g.cw;
     let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg);
     p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator));
@@ -1980,6 +2547,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             htext(x + 6.0, s);
         }
     }
+}
+
+/// Column header clicks, automation ids and the right-click column menu.
+fn column_header_interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
+    let rect = g.rect;
+    let header_h = g.header_h;
+    let colhdr_h = g.colhdr_h;
+    let vis = g.vis;
+    let graph_x0 = g.graph_x0;
+    let top = g.top;
+    let ctx = g.ctx.clone();
+    let cw = &g.cw;
+    let actions = &mut g.actions;
+    let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     let hdr_rect = Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y));
     let hresp = ui.interact(hdr_rect, egui::Id::unique("tl-colhdr"), Sense::click());
     app.auto.add("timeline.header", hdr_rect, "Columns (right-click)");
@@ -2004,12 +2585,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     if hresp.clicked()
         && let Some(pt) = hresp.interact_pointer_pos()
-        && column_at(&cw, pt.x) == Some("name")
+        && column_at(cw, pt.x) == Some("name")
     {
         app.ui.timeline.source_name = !app.ui.timeline.source_name;
     }
     if hresp.secondary_clicked() {
-        let under = hresp.interact_pointer_pos().and_then(|pt| column_at(&cw, pt.x));
+        let under = hresp.interact_pointer_pos().and_then(|pt| column_at(cw, pt.x));
         ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-colhdr-under"), under.unwrap_or("")));
     }
     crate::widgets::context_menu(&hresp, |ui| {
@@ -2034,12 +2615,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     });
     let _ = cw.end;
+}
 
-    // ---- rows.
-    let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx);
-    let rh = t.row_h;
-    let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
-    let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
+/// Scrolls an expression editor or the requested layer / property into view.
+fn reveal_scroll(app: &mut EffectcraftApp, g: &Tl) {
+    let scroll_to = g.scroll_to;
+    let rows_rect = g.rows_rect;
+    let rh = g.rh;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let rows = &g.rows;
     // An expression editor asked to take the keyboard comes into view; a request whose
     // expression didn't appear, or that wasn't taken within a few passes, is dropped (it never
     // takes the keyboard later).
@@ -2065,6 +2650,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
     }
+}
+
+/// Mouse wheel and pinch over the rows: zoom time or scroll.
+fn wheel_zoom_scroll(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let overflow = g.overflow;
+    let graph_x0 = g.graph_x0;
+    let fit_pps = g.fit_pps;
+    let pps = g.pps;
+    let tm = g.tm;
+    let rows_rect = g.rows_rect;
+    let max_scroll = g.max_scroll;
+    let comp = g.comp.clone();
     if ui.rect_contains_pointer(rows_rect) {
         let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
         // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll
@@ -2090,6 +2687,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// A held pick whip near the top or bottom scrolls the rows.
+fn pick_whip_edge_scroll(app: &mut EffectcraftApp, g: &Tl) {
+    let rect = g.rect;
+    let rows_rect = g.rows_rect;
+    let rh = g.rh;
+    let max_scroll = g.max_scroll;
+    let ctx = g.ctx.clone();
     // A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in
     // After Effects.
     if ctx.data(|d| d.get_temp::(pick_whip_id())).is_some()
@@ -2102,1095 +2708,1453 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll);
-    let snap_project = app.session.project.clone();
-    let snap_expr = app.session.expr.clone();
-    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
-    let lp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
-    let gp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
-    let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
-    let selected = app.session.state.selected_layers.clone();
-    let sel_keys = app.session.state.selected_keys.clone();
-    let mut ui_actions: Vec = Vec::new();
-    let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0);
-    let graph_on = app.ui.timeline.graph_editor;
-    super::graph::track_open(ui.ctx(), graph_on);
-    let full_clip = ui.clip_rect();
-    let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
-    let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
-    // Registered before the rows so keys, bars and editors on top of it get the pointer first.
-    let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
-    let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
-    let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
-    let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
-    app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
-    let mut hit_rows: Vec<(Rect, Row)> = vec![];
-    // The value fields of multi-dimension properties (per dimension), for the pick whip.
-    let mut dim_hits: Vec<(u64, Vec)> = vec![];
-    // Rows stripe by their index among the rows that are not expression editors, so adding or
-    // removing an expression leaves every other row's stripe alone; an editor takes the index
-    // (and so the stripe) of its property's row, the one just above.
-    let mut striped = 0usize;
-    for row in rows.iter() {
-        let ri = if matches!(row.kind, RowKind::Expr { .. }) {
-            striped.saturating_sub(1)
-        } else {
-            striped += 1;
-            striped - 1
-        };
-        // Outline widgets never spill into the time graph (narrow timelines).
-        ui.set_clip_rect(left_clip);
-        let hh = row_height(row, rh);
-        let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
-        y += hh;
-        if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
+}
+
+fn layer_row_background(g: &Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let r = rc.r;
+    let left = rc.left;
+    let is_sel = rc.is_sel;
+    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 { t.tl_graph_selected } 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));
+}
+
+/// The A/V feature switches (video, audio, solo, lock).
+fn layer_av_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let sw = &layer.switches;
+    // Every layer has the Video switch (the eye), nulls, cameras and lights included,
+    // as in After Effects: it hides a null's outline in the viewer and turns a camera or
+    // light off.
+    let av_items: [(Icon, bool, &str, bool); 4] = [
+        (Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)),
+        (Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)),
+        (Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)),
+        (Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)),
+    ];
+    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(15.0, 15.0));
+        if !applicable || !vis.av {
             continue;
         }
-        hit_rows.push((r, row.clone()));
-        let Some(layer) = comp.layer(row.layer) else { continue };
-        let is_sel = selected.contains(&layer.id);
-        let left = Rect::from_min_max(r.min, pos2(graph_x0 - 1.0, r.max.y));
-        let cy = r.min.y + rh / 2.0;
-        match &row.kind {
-            RowKind::Layer => {
-                // 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 { t.tl_graph_selected } 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;
-                // Every layer has the Video switch (the eye), nulls, cameras and lights included,
-                // as in After Effects: it hides a null's outline in the viewer and turns a camera or
-                // light off.
-                let av_items: [(Icon, bool, &str, bool); 4] = [
-                    (Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)),
-                    (Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)),
-                    (Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)),
-                    (Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)),
-                ];
-                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(15.0, 15.0));
-                    if !applicable || !vis.av {
-                        continue;
-                    }
-                    lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
-                    layer_switch(
-                        ui,
-                        &t,
-                        r,
-                        br,
-                        icon,
-                        on,
-                        egui::Id::unique(("av", layer.id.0, i)),
-                        layer.id.0,
-                        name,
-                        true,
-                        &comp,
-                        &app.session.project,
-                        &selected,
-                        &mut actions,
-                    );
-                    app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
-                }
-                // Label swatch.
-                if vis.label {
-                    let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
-                    lp.rect_filled(lr, 2.0, t.label(layer.label));
-                    let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
-                    app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
-                    // Click (or right-click) the swatch for the label menu (After Effects).
-                    let pop = egui::Id::unique(("label-pop", layer.id.0));
-                    if lresp.clicked() || lresp.secondary_clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
-                        actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, &selected), "label": lab.name()})));
-                    }
-                }
-                if vis.num {
-                    lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim);
-                }
-                layer_cells(app, ui, &lp, &cw, &comp, layer, r, cy, &mut actions);
-                // Row click → select; double-click → rename; drag → reorder. Registered before the
-                // twirl and the name field so those sit on top and get their clicks.
-                let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
-                let row_resp = ui.interact(
-                    Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)),
-                    egui::Id::unique(("row", layer.id.0)),
-                    Sense::click_and_drag(),
-                );
-                // Twirl + icon + name.
-                let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
-                let open = app.ui.timeline.open_layers.contains(&layer.id.0);
-                if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
-                    ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
-                }
-                app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
-                icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
-                // A guide layer carries a guide mark before its name (After Effects).
-                let name_x = if layer.switches.guide {
-                    let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0));
-                    icons::paint(&lp, mark, Icon::Grid, t.text_dim);
-                    app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer");
-                    cw.name + 50.0
-                } else {
-                    cw.name + 34.0
-                };
-                let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
-                let rename_id = egui::Id::unique("tl-rename");
-                let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
-                if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
-                    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
-                    let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
-                    app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
-                    if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
-                        // Just started: focus the field with the whole name selected.
-                        er.request_focus();
-                        widgets::select_all(&ctx, er.id, &buf);
-                    }
-                    if er.lost_focus() {
-                        if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
-                            actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
-                        }
-                        ctx.data_mut(|d| d.remove::<(u64, String)>(rename_id));
-                    } else {
-                        ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
-                    }
-                } else {
-                    // 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);
-                }
-                // Locked layers can't be selected or moved (unlock them with the lock switch).
-                let locked = layer.switches.locked;
-                if row_resp.drag_started() && !locked {
-                    // The selected layers move together; an unselected row moves alone.
-                    let moving: Vec =
-                        if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
-                    ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving));
-                }
-                if row_resp.drag_stopped() {
-                    ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
-                }
-                app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
-                if row_resp.clicked() && !locked {
-                    let m = ui.input(|i| i.modifiers);
-                    if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
-                        actions.push(("layer.select".into(), json!({"layers": layers})));
-                    } else {
-                        actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
-                        set_anchor(&ctx, cid, layer.id);
-                    }
-                }
-                if row_resp.double_clicked() {
-                    match double_click_action(layer) {
-                        Some(a) => actions.push(a),
-                        None => start_rename(&ctx, layer.id.0, &layer.name),
-                    }
-                }
-                layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions);
-                // Switches.
-                let sws: [(Icon, bool, &str, bool); 8] = [
-                    (Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)),
-                    (Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)),
-                    (
-                        Icon::Quality,
-                        sw.quality == effectcraft_engine::project::Quality::Best,
-                        "quality",
-                        switch_applies(layer, "quality", &app.session.project),
-                    ),
-                    (Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)),
-                    (
-                        Icon::FrameBlend,
-                        sw.frame_blend != effectcraft_engine::project::FrameBlend::Off,
-                        "frameBlend",
-                        switch_applies(layer, "frameBlend", &app.session.project),
-                    ),
-                    (Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)),
-                    (Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)),
-                    (Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)),
-                ];
-                for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() {
-                    if !applicable || !vis.switches {
-                        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_filled(br.shrink(1.0), 1.0, t.switch_well);
-                    layer_switch(
-                        ui,
-                        &t,
-                        r,
-                        br,
-                        icon,
-                        on,
-                        egui::Id::unique(("sw", layer.id.0, i)),
-                        layer.id.0,
-                        name,
-                        false,
-                        &comp,
-                        &app.session.project,
-                        &selected,
-                        &mut actions,
-                    );
-                    app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
-                }
-                // Modes.
-                if app.ui.timeline.show_modes && layer.source.is_av() {
-                    let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
-                    let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
-                    let pop = egui::Id::unique(("mode-pop", layer.id.0));
-                    if mresp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let opts: Vec = BlendMode::ALL
-                        .iter()
-                        .flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] })
-                        .collect();
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) {
-                        actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "mode": opts[i]})));
-                    }
-                    let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
-                    app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
-                    if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
-                        // The click gives the selection the state the clicked row toggles to,
-                        // whatever the first selected layer's value (as the switches do).
-                        actions.push((
-                            "layer.setSwitch".into(),
-                            json!({
-                                "layers": mode_targets(&comp, layer.id, &selected),
-                                "switch": "preserveTransparency",
-                                "value": !layer.preserve_transparency
-                            }),
-                        ));
-                    }
-                    // Track matte: layer picker + kind.
-                    let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0));
-                    let label = match layer.track_matte {
-                        Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), l.name)).unwrap_or("None".into()),
-                        None => "No Track Matte".into(),
-                    };
-                    // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
-                    let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
-                    let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
-                    let pop = egui::Id::unique(("trk-pop", layer.id.0));
-                    if tresp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    // The menu lists every layer: built only while it is open (per visible row
-                    // it made a big comp's timeline quadratic).
-                    let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
-                    let mut candidates: Vec<&Layer> = vec![];
-                    if widgets::popup_is_open(ui, pop) {
-                        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
-                            opts.push(format!("{}. {}", i + 1, l.name));
-                            candidates.push(l);
-                        }
-                        opts.push("-".into());
-                        for k in MatteKind::ALL {
-                            opts.push(k.label().into());
-                        }
-                    }
-                    let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
-                        let ids: Vec = candidates.iter().map(|l| l.id).collect();
-                        if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) {
-                            actions.push(a);
-                        }
-                    }
-                    if layer.track_matte.is_some() {
-                        let kr = Rect::from_center_size(pos2(mr.max.x - 26.0, cy), vec2(10.0, 10.0));
-                        let _ = kr;
-                    }
-                }
-                // Parent.
-                if vis.parent {
-                    pick_whip(
-                        app,
-                        ui,
-                        &lp,
-                        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
-                        (WHIP_PARENT, layer.id.0, 0),
-                        &format!("timeline.layer.{}.pickWhip", layer.id.0),
-                        "Parent pick whip",
-                        crate::i18n::tr("Parent pick whip: drag onto a layer"),
-                    );
-                    let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
-                    let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into());
-                    let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
-                    let pop = egui::Id::unique(("par-pop", layer.id.0));
-                    if presp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let mut cands: Vec<&Layer> = vec![];
-                    let mut popts = vec!["None".to_string(), "-".to_string()];
-                    if widgets::popup_is_open(ui, pop) {
-                        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
-                            popts.push(format!("{}. {}", i + 1, l.name));
-                            cands.push(l);
-                        }
-                    }
-                    if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
-                        let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
-                        actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
-                    }
-                }
-                // Layer bar.
-                ui.set_clip_rect(right_clip);
-                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 + 1.0), pos2(x_out, r.max.y - 1.0));
-                    let lc = t.label(layer.label);
-                    let fill = bar_color(lc, is_sel);
-                    gp.rect_filled(bar, 0.0, fill);
-                    if is_sel {
-                        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
-                        && let Some(d) = layer.source.item().and_then(|i| app.session.project.item(i)).and_then(|i| i.duration())
-                    {
-                        let xs = tm.x(layer.start_time.seconds());
-                        let xe = tm.x(layer.comp_time(d).seconds());
-                        let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
-                        gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
-                        // The bar past the source's end (its last frame held) or before its start
-                        // is striped, so the source's own span shows (#290). Time remapping
-                        // decides what shows there itself.
-                        if layer.props.get("timeRemap").is_none() {
-                            for (side, part) in [
-                                ("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
-                                ("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
-                            ] {
-                                if part.width() >= 1.0 {
-                                    stripes(&gp, part, lc);
-                                    app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
-                                }
-                            }
-                        }
-                        // Dragging the source bar outside the in/out span slips the source.
-                        for (side, gr) in [
-                            ("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))),
-                            ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max)),
-                        ] {
-                            if gr.width() < 2.0 {
-                                continue;
-                            }
-                            let gresp = ui
-                                .interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag())
-                                .on_hover_text(crate::i18n::tr("Drag to slip the source"));
-                            // Outside the bar it is empty track area: a plain click deselects.
-                            if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
-                                actions.push(("layer.select".into(), json!({"layers": []})));
-                            }
-                            app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
-                            if gresp.hovered() || gresp.dragged() {
-                                ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
-                            }
-                            let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
-                            if gresp.dragged() {
-                                let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
-                                acc += gresp.drag_delta().x;
-                                let frames = ((acc as f64 / pps) / fd).trunc() as i64;
-                                if frames != 0 {
-                                    acc -= (frames as f64 * fd * pps) as f32;
-                                    actions.push((
-                                        "layer.slip".into(),
-                                        json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)}),
-                                    ));
-                                }
-                                ctx.data_mut(|d| d.insert_temp(acc_id, acc));
-                            }
-                            if gresp.drag_stopped() {
-                                ctx.data_mut(|d| d.remove::(acc_id));
-                                ui_actions.push(UiAct::EndMerge);
-                            }
-                        }
-                    }
-                    app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
-                    // Interactions: move body, trim edges.
-                    let (body_r, in_r, out_r) = trim_zones(bar);
-                    let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
-                    let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
-                    let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
-                    app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
-                    app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
-                    if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
-                        trim_cursor(&ctx);
-                    }
-                    if body.double_clicked()
-                        && let Some(a) = double_click_action(layer)
-                    {
-                        actions.push(a);
-                    }
-                    let shift = ui.input(|i| i.modifiers.shift);
-                    if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
-                        // Shift-click: the layers from the last one clicked to this one, as in
-                        // the layer outline.
-                        actions.push(("layer.select".into(), json!({"layers": layers})));
-                    } else {
-                        if (body.clicked() || body.drag_started()) && !is_sel {
-                            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
-                        }
-                        if (body.clicked() || body.drag_started()) && !shift {
-                            set_anchor(&ctx, cid, layer.id);
-                        }
-                    }
-                    layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions);
-                    let drag_key = format!("bar-{}", layer.id.0);
-                    // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the
-                    // drag starts and followed below the rows until the button is released.
-                    let mods = ui.input(|i| i.modifiers);
-                    if body.drag_started()
-                        && is_sel
-                        && mods.command
-                        && mods.alt
-                        && let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
-                        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
-                    {
-                        ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
-                    }
-                    let quick = ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id);
-                    for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
-                        if kind == "move" && quick {
-                            continue;
-                        }
-                        if resp.dragged() {
-                            let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
-                            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
-                            acc += resp.drag_delta().x;
-                            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
-                            if frames != 0 {
-                                acc -= (frames as f64 * fd * pps) as f32;
-                                let d = frames as f64 * fd;
-                                // Moving or trimming a selected layer acts on the whole
-                                // selection; each layer trims from its own in/out point.
-                                let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) };
-                                let params = match kind {
-                                    "move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
-                                    "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
-                                    _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
-                                };
-                                actions.push(("layer.timing".into(), params));
-                            }
-                            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
-                        }
-                        if resp.drag_stopped() {
-                            ctx.data_mut(|d| d.remove::(egui::Id::unique(("bar-acc", layer.id.0, kind))));
-                            ui_actions.push(UiAct::EndMerge);
-                        }
-                    }
-                    // Markers on the layer (drag, Alt-drag duration, Cmd-click delete, dialog).
-                    let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
-                    super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
-                }
+        lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
+        layer_switch(
+            ui,
+            &t,
+            &SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("av", layer.id.0, i)), layer: layer.id.0, name, others: true },
+            &comp,
+            &app.session.project,
+            selected,
+            &mut *actions,
+        );
+        app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
+    }
+}
+
+/// The label swatch and its colour menu.
+fn layer_label_swatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    // Label swatch.
+    if vis.label {
+        let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
+        lp.rect_filled(lr, 2.0, t.label(layer.label));
+        let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
+        app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
+        // Click (or right-click) the swatch for the label menu (After Effects).
+        let pop = egui::Id::unique(("label-pop", layer.id.0));
+        if lresp.clicked() || lresp.secondary_clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
+            actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, selected), "label": lab.name()})));
+        }
+    }
+}
+
+fn layer_number_and_cells(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    if vis.num {
+        lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", comp.index_of(layer.id).unwrap_or(0)), Tokens::ui(11.5), t.text_dim);
+    }
+    layer_cells(app, ui, &lp, cw, &comp, rc, &mut *actions);
+}
+
+/// The row's click target, twirl, icon and guide mark; returns the row response and the name's x.
+fn layer_twirl_and_icon(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) -> (egui::Response, f32) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    // Row click → select; double-click → rename; drag → reorder. Registered before the
+    // twirl and the name field so those sit on top and get their clicks.
+    let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
+    let row_resp =
+        ui.interact(Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), egui::Id::unique(("row", layer.id.0)), Sense::click_and_drag());
+    // Twirl + icon + name.
+    let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
+    let open = app.ui.timeline.open_layers.contains(&layer.id.0);
+    if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
+        ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
+    }
+    app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
+    icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
+    // A guide layer carries a guide mark before its name (After Effects).
+    let name_x = if layer.switches.guide {
+        let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0));
+        icons::paint(&lp, mark, Icon::Grid, t.text_dim);
+        app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer");
+        cw.name + 50.0
+    } else {
+        cw.name + 34.0
+    };
+    (row_resp, name_x)
+}
+
+/// The layer name: the rename field or the (selected) name cell.
+fn layer_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, name_x: f32) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let is_sel = rc.is_sel;
+    let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
+    let rename_id = egui::Id::unique("tl-rename");
+    let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
+    if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
+        let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
+        app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
+        if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
+            // Just started: focus the field with the whole name selected.
+            er.request_focus();
+            widgets::select_all(&ctx, er.id, &buf);
+        }
+        if er.lost_focus() {
+            if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+                actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
             }
-            RowKind::Group { uid, name, open, has_children, fx, eye } => {
-                // The row draws an effect's name in the interface language (#821); `name` stays
-                // the property tree's, which the automation labels below keep. Any other group
-                // (a mask, a shape group, a layer style) draws its own name.
-                let group = layer.props.find_group(*uid);
-                let shown = match group.and_then(|g| match &g.kind {
-                    GroupKind::Effect { effect } => Some(effect.as_str()),
-                    _ => None,
-                }) {
-                    Some(effect) => crate::i18n::effects::group(effect, name),
-                    None => name.clone(),
-                };
-                // Selected groups (an effect, a mask, a shape group…) highlight like selected
-                // property rows.
-                let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_group {
-                        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, t.tl_bg);
-                // An effect's colour label (26.5): the same bar the Effect Controls header wears,
-                // on the row's left edge.
-                if let Some(lab) =
-                    group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
-                {
-                    let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
-                    let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
-                    lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
-                    app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
-                }
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                if *has_children {
-                    let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
-                    if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
-                        // Alt/Cmd-click twirls the whole tree under the group (as on a layer).
-                        ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name);
-                }
-                if let Some(en) = fx {
-                    let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
-                    if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
-                        actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
-                }
-                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(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::unique(("geye", uid)), Sense::click()).clicked() {
-                        actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
-                }
-                let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id()));
-                if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) {
-                    let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0));
-                    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field));
-                    let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width()));
-                    app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf);
-                    if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
-                        er.request_focus();
-                        widgets::select_all(&ctx, er.id, &buf);
-                    }
-                    if er.lost_focus() {
-                        if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name {
-                            actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf})));
-                        }
-                        ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id()));
-                    } else {
-                        ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf)));
-                    }
-                } else {
-                    lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), t.text);
-                }
-                text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                if *uid == GEOM_BIT | layer.id.0 {
-                    let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
-                    let resp = ui
-                        .interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
-                        .on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
-                    if resp.hovered() {
-                        ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
-                    }
-                    app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
-                    if resp.clicked() {
-                        ui_actions.push(UiAct::ChangeRenderer);
-                    }
-                }
-                dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                // Mask mode + inverted inline.
-                if let Some(g) = layer.props.find_group(*uid)
-                    && let GroupKind::Mask { mode, inverted, .. } = g.kind
-                {
-                    let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
-                    let pop = egui::Id::unique(("maskmode", uid));
-                    if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let opts: Vec = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
-                        actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
-                    }
-                    let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
-                    if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
-                        actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
-                    }
-                    lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
-                }
-                let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
-                let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
-                app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
-                if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
-                    actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
-                }
-                if gr.double_clicked() {
-                    if fx.is_some() {
-                        // An effect's name: open it in Effect Controls (as in After Effects).
-                        actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
-                        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
-                        actions.push(("window.panel".into(), json!({"panel": "EffectControls"})));
-                    } else {
-                        ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false));
-                    }
-                }
+            ctx.data_mut(|d| d.remove::<(u64, String)>(rename_id));
+        } else {
+            ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
+        }
+    } else {
+        // 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);
+    }
+}
+
+/// Row click, drag, double-click and context menu.
+fn layer_row_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, row_resp: &egui::Response) {
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let left = rc.left;
+    let is_sel = rc.is_sel;
+    // Locked layers can't be selected or moved (unlock them with the lock switch).
+    let locked = layer.switches.locked;
+    if row_resp.drag_started() && !locked {
+        // The selected layers move together; an unselected row moves alone.
+        let moving: Vec = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
+        ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving));
+    }
+    if row_resp.drag_stopped() {
+        ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
+    }
+    app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
+    if row_resp.clicked() && !locked {
+        let m = ui.input(|i| i.modifiers);
+        if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
+            actions.push(("layer.select".into(), json!({"layers": layers})));
+        } else {
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
+            set_anchor(&ctx, cid, layer.id);
+        }
+    }
+    if row_resp.double_clicked() {
+        match double_click_action(layer) {
+            Some(a) => actions.push(a),
+            None => start_rename(&ctx, layer.id.0, &layer.name),
+        }
+    }
+    layer_context_menu(row_resp, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
+}
+
+/// The eight layer switches.
+fn layer_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let sw = &layer.switches;
+    let sws: [(Icon, bool, &str, bool); 8] = [
+        (Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)),
+        (Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)),
+        (Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", switch_applies(layer, "quality", &app.session.project)),
+        (Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)),
+        (
+            Icon::FrameBlend,
+            sw.frame_blend != effectcraft_engine::project::FrameBlend::Off,
+            "frameBlend",
+            switch_applies(layer, "frameBlend", &app.session.project),
+        ),
+        (Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)),
+        (Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)),
+        (Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)),
+    ];
+    for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() {
+        if !applicable || !vis.switches {
+            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_filled(br.shrink(1.0), 1.0, t.switch_well);
+        layer_switch(
+            ui,
+            &t,
+            &SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("sw", layer.id.0, i)), layer: layer.id.0, name, others: false },
+            &comp,
+            &app.session.project,
+            selected,
+            &mut *actions,
+        );
+        app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
+    }
+}
+
+/// The blend mode dropdown and Preserve Transparency.
+fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
+    let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
+    let pop = egui::Id::unique(("mode-pop", layer.id.0));
+    if mresp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    let opts: Vec =
+        BlendMode::ALL.iter().flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] }).collect();
+    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) {
+        actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, selected), "mode": opts[i]})));
+    }
+    let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
+    app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
+    if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::unique(("pt", layer.id.0))).clicked() {
+        // The click gives the selection the state the clicked row toggles to,
+        // whatever the first selected layer's value (as the switches do).
+        actions.push((
+            "layer.setSwitch".into(),
+            json!({
+                "layers": mode_targets(&comp, layer.id, selected),
+                "switch": "preserveTransparency",
+                "value": !layer.preserve_transparency
+            }),
+        ));
+    }
+}
+
+/// The track matte picker.
+fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    // Track matte: layer picker + kind.
+    let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0));
+    let label = match layer.track_matte {
+        Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or("None".into()),
+        None => "No Track Matte".into(),
+    };
+    // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
+    let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
+    let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
+    let pop = egui::Id::unique(("trk-pop", layer.id.0));
+    if tresp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    // The menu lists every layer: built only while it is open (per visible row
+    // it made a big comp's timeline quadratic).
+    let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
+    let mut candidates: Vec<&Layer> = vec![];
+    if widgets::popup_is_open(ui, pop) {
+        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
+            opts.push(format!("{}. {}", i + 1, l.name));
+            candidates.push(l);
+        }
+        opts.push("-".into());
+        for k in MatteKind::ALL {
+            opts.push(k.label().into());
+        }
+    }
+    let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
+    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
+        let ids: Vec = candidates.iter().map(|l| l.id).collect();
+        if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) {
+            actions.push(a);
+        }
+    }
+    if layer.track_matte.is_some() {
+        let kr = Rect::from_center_size(pos2(mr.max.x - 26.0, cy), vec2(10.0, 10.0));
+        let _ = kr;
+    }
+}
+
+/// The parent pick whip and dropdown.
+fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    pick_whip(
+        app,
+        ui,
+        &lp,
+        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
+        (WHIP_PARENT, layer.id.0, 0),
+        WhipText {
+            auto: &format!("timeline.layer.{}.pickWhip", layer.id.0),
+            label: "Parent pick whip",
+            hint: crate::i18n::tr("Parent pick whip: drag onto a layer"),
+        },
+    );
+    let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
+    let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or_else(|| "None".into());
+    let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
+    let pop = egui::Id::unique(("par-pop", layer.id.0));
+    if presp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    let mut cands: Vec<&Layer> = vec![];
+    let mut popts = vec!["None".to_string(), "-".to_string()];
+    if widgets::popup_is_open(ui, pop) {
+        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
+            popts.push(format!("{}. {}", i + 1, l.name));
+            cands.push(l);
+        }
+    }
+    if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
+        let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
+        actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
+    }
+}
+
+/// Paints the layer bar; returns it and its label colour.
+fn layer_bar_paint(g: &Rw, rc: &RowCtx<'_>) -> (Rect, Color32) {
+    let t = g.tl.t;
+    let tm = g.tl.tm;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let is_sel = rc.is_sel;
+    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 + 1.0), pos2(x_out, r.max.y - 1.0));
+    let lc = t.label(layer.label);
+    let fill = bar_color(lc, is_sel);
+    gp.rect_filled(bar, 0.0, fill);
+    if is_sel {
+        gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside);
+    }
+    (bar, lc)
+}
+
+/// Dragging the source bar outside the in/out span slips the source.
+fn slip_source_handles(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ghost: Rect, bar: Rect) {
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    // Dragging the source bar outside the in/out span slips the source.
+    for (side, gr) in [("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max))] {
+        if gr.width() < 2.0 {
+            continue;
+        }
+        let gresp =
+            ui.interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag()).on_hover_text(crate::i18n::tr("Drag to slip the source"));
+        // Outside the bar it is empty track area: a plain click deselects.
+        if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
+            actions.push(("layer.select".into(), json!({"layers": []})));
+        }
+        app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
+        if gresp.hovered() || gresp.dragged() {
+            ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
+        }
+        let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
+        if gresp.dragged() {
+            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
+            acc += gresp.drag_delta().x;
+            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
+            if frames != 0 {
+                acc -= (frames as f64 * fd * pps) as f32;
+                actions.push(("layer.slip".into(), json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)})));
             }
-            RowKind::Waveform { item } => {
-                lp.rect_filled(left, 0.0, if ri % 2 == 0 { t.row } else { t.row_alt });
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text);
-                let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
-                gp.rect_filled(wr, 0.0, t.tl_bg);
-                app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
-                match super::waveform::summary(app, &ctx, ItemId(*item)) {
-                    Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
-                    None => {
-                        gp.text(
-                            pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
-                            Align2::LEFT_CENTER,
-                            "Building waveform…",
-                            Tokens::ui(11.0),
-                            t.text_faint,
-                        );
-                    }
-                }
-            }
-            RowKind::Expr { uid, lines } => {
-                let Some(prop) = layer.props.find(*uid) else { continue };
-                let Some(ex) = prop.expr.clone() else { continue };
-                let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::unique(("expr-edit", uid)));
-                // The same background as its property's row, selection included.
-                let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_prop {
-                        t.row_selected
-                    } else if ri % 2 == 0 {
-                        t.row
-                    } else {
-                        t.row_alt
-                    },
-                );
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
-                lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
-                // "=" enable switch and the property pick whip (AE's expression controls).
-                let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
-                let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
-                lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
-                let expr_col = t.expr_text;
-                lp.text(
-                    en_r.center(),
-                    Align2::CENTER_CENTER,
-                    if ex.enabled { "=" } else { "≠" },
-                    Tokens::semibold(13.0),
-                    if ex.enabled { expr_col } else { t.text_dim },
-                );
-                app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
-                if en.clicked() {
-                    actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
-                }
-                let pw = pick_whip(
-                    app,
-                    ui,
-                    &lp,
-                    Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)),
-                    (WHIP_EXPR, layer.id.0, *uid),
-                    &format!("timeline.prop.{uid}.pickWhip"),
-                    "Expression pick whip",
-                    crate::i18n::tr("Expression pick whip: drag onto a property"),
-                );
-                if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
-                    // Pressed while the expression is being edited (its text is buffered while
-                    // the editor has the focus): the reference will go in at the cursor,
-                    // replacing the selection (#284).
-                    let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| {
-                        let n = buf.chars().count();
-                        let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
-                            .and_then(|st| st.cursor.char_range())
-                            .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
-                        (buf, sel)
-                    });
-                    ctx.data_mut(|d| {
-                        if let Some(into) = into {
-                            d.insert_temp(whip_into_id(*uid), into);
-                        } else {
-                            d.remove::(whip_into_id(*uid));
-                        }
-                    });
-                }
-                // Syntax errors: warning in the outline (the expression is disabled).
-                if let Some(check) = app.session.expr_check
-                    && let Err(e) = check(&ex.text)
-                {
-                    let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
-                    lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
-                    let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
-                }
-                // ⓕ: the Expression Language menu inserts at the cursor.
-                let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
-                let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
-                lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
-                lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
-                app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
-                let mut picked: Option<&'static str> = None;
-                egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
-                    for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
-                        ui.menu_button(cat, |ui| {
-                            widgets::menu_scroll(ui, false, |ui| {
-                                for (label, text) in items {
-                                    if ui.button(label).clicked() {
-                                        picked = Some(text);
-                                        ui.close();
-                                    }
-                                }
-                            })
-                        });
-                    }
-                });
-                if let Some(text) = picked {
-                    let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
-                    let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
-                        .and_then(|st| st.cursor.char_range())
-                        .map(|r| r.primary.index.0)
-                        .unwrap_or(cur.chars().count())
-                        .min(cur.chars().count());
-                    let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
-                    let mut next = cur.clone();
-                    next.insert_str(byte, text);
-                    actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
-                    ctx.data_mut(|d| d.remove::(buf_id));
-                }
-                // The editor in the time-graph area.
-                ui.set_clip_rect(right_clip);
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
-                let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
-                let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
-                if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) {
-                    // Asked for once (a pick whip put a reference in, or an Alt-click added the
-                    // expression): editing starts in the expression as it is now.
-                    ctx.data_mut(|d| d.remove::(expr_focus_id()));
-                    buf = ex.text.clone();
-                    let n = buf.chars().count();
-                    let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
-                    let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
-                    st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
-                    st.store(&ctx, edit_id);
-                    ctx.memory_mut(|m| m.request_focus(edit_id));
-                }
-                // Settings ▸ Scripting & Expressions ▸ Expressions Editor.
-                let sp = app.session.prefs.scripting.clone();
-                let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
-                // Its bottom edge drags to set its height in whole lines, as in After Effects.
-                let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
-                let eresp = ui.interact(edge, egui::Id::unique(("expr-edge", uid)), Sense::drag());
-                app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
-                if eresp.hovered() || eresp.dragged() {
-                    ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
-                }
-                if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
-                    let n = ((py - r.min.y) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
-                    app.ui.timeline.expr_lines.insert(*uid, n as usize);
-                }
-                let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
-                if commit {
-                    resp.surrender_focus();
-                }
-                if resp.has_focus() && !commit {
-                    ctx.data_mut(|d| d.insert_temp(buf_id, buf));
-                } else {
-                    if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
-                        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
-                    }
-                    ctx.data_mut(|d| d.remove::(buf_id));
-                }
-            }
-            RowKind::Prop { uid } => {
-                let Some(prop) = layer.props.find(*uid) else { continue };
-                app.auto.add(&format!("timeline.prop.{uid}.row"), r, &prop.name);
-                let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_prop {
-                        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, t.tl_bg);
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                // Keyframe navigator (in the A/V column, like AE).
-                // (in the Keys column when it is shown).
-                if prop.is_animated() && (vis.keys || vis.av) {
-                    let lt = layer.layer_time(time);
-                    let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some();
-                    let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 };
-                    let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0));
-                    let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0));
-                    let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0));
-                    icons::paint(&lp, prev.shrink(2.0), Icon::ChevronLeft, t.text_dim);
-                    icons::paint(&lp, next.shrink(2.0), Icon::ChevronRight, t.text_dim);
-                    let kc = if at_key { t.keyframe_selected } else { Color32::TRANSPARENT };
-                    lp.add(egui::Shape::convex_polygon(
-                        vec![mid.center() + vec2(0.0, -5.0), mid.center() + vec2(5.0, 0.0), mid.center() + vec2(0.0, 5.0), mid.center() + vec2(-5.0, 0.0)],
-                        kc,
-                        Stroke::new(1.0, t.text_dim),
-                    ));
-                    if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
-                        && let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
-                    {
-                        ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
-                    }
-                    if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
-                        && let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
-                    {
-                        ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
-                    }
-                    if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
-                        actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
-                    }
-                    app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
-                }
-                // Stopwatch: Alt+click adds an expression; a press toggles the animation, and
-                // dragging on over other stopwatches gives them the same state (#284).
-                if !prop.static_only {
-                    let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
-                    let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
-                    let col = if prop.is_animated() {
-                        t.hot_text
-                    } else if resp.hovered() {
-                        t.text
-                    } else {
-                        t.text_dim
-                    };
-                    icons::paint(&lp, swr, Icon::Stopwatch, col);
-                    app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
-                    if ui.input(|i| i.modifiers.alt) {
-                        if resp.clicked() {
-                            actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
-                            if prop.expr.is_none() {
-                                // Adding one: its editor opens, comes into view and takes the
-                                // keyboard with the new expression selected (#1072).
-                                app.ui.timeline.expr_closed.remove(uid);
-                                focus_expression(&ctx, *uid, [usize::MAX, 0]);
-                            } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
-                                // Removing it: nothing is left to take the keyboard.
-                                ctx.data_mut(|d| d.remove::(expr_focus_id()));
-                            }
-                        }
-                    } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
-                        actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
-                    } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
-                        // Keyboard or accessibility activation.
-                        actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
-                    }
-                }
-                let name_x = indent + 12.0;
-                // 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
-                let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
-                let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
-                let pname = if prop.has_expression() { format!("{base}  =") } else { base };
-                lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
-                let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
-                let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
-                app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
-                if name_resp.drag_started() {
-                    // Drop on the Essential Graphics panel to expose the property.
-                    egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
-                }
-                if name_resp.dragged() {
-                    egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer)
-                        .show(|ui| ui.label(&prop.name));
-                }
-                if name_resp.clicked() {
-                    actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
-                }
-                crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions));
-                // Value editors.
-                let value = ectx.value(layer, prop);
-                let vx = (cw.switches + 4.0).max(name_x + 120.0);
-                let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
-                if !dims.is_empty() {
-                    dim_hits.push((*uid, dims));
-                }
-                // The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on
-                // a property it links this one to it, adding the expression if there is none.
-                if vis.parent && !prop.static_only {
-                    pick_whip(
-                        app,
-                        ui,
-                        &lp,
-                        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
-                        (WHIP_PROP, layer.id.0, *uid),
-                        &format!("timeline.prop.{uid}.propertyPickWhip"),
-                        "Property pick whip",
-                        crate::i18n::tr("Property pick whip: drag onto a property"),
-                    );
-                }
-                ui.set_clip_rect(right_clip);
-                // Expression text row hint.
-                if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
-                    gp.text(
-                        pos2(graph_x0 + 8.0, cy),
-                        Align2::LEFT_CENTER,
-                        format!("= {}", e.text.lines().next().unwrap_or("")),
-                        Tokens::mono(11.0),
-                        t.expr_text,
-                    );
-                }
-                // Keyframes.
-                if !graph_on {
-                    for k in &prop.keys {
-                        let ct = layer.comp_time(k.time);
-                        let x = tm.x(ct.seconds());
-                        if x < graph_x0 - 8.0 || x > rect.max.x + 8.0 {
-                            continue;
-                        }
-                        let kref = effectcraft_engine::KeyRef { layer: layer.id, prop: *uid, time: k.time };
-                        let ks = sel_keys.contains(&kref);
-                        let icon = k.icon();
-                        let c = pos2(x, cy);
-                        // Labelled keys take their label colour (brighter when selected).
-                        let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
-                            Some(l) => {
-                                let c = t.label(*l);
-                                if ks { c } else { c.gamma_multiply(0.75) }
-                            }
-                            None if ks => t.keyframe_selected,
-                            None => t.keyframe,
-                        };
-                        if k.roving {
-                            // Roving keys: a small dot (their time follows their neighbours).
-                            gp.circle_filled(c, 2.5, kcol);
-                        } else {
-                            icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
-                        }
-                        let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
-                        let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
-                        app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
-                        // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
-                        app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
-                        let mods = ui.input(|i| i.modifiers);
-                        let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
-                        if kresp.clicked() && mods.command {
-                            // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
-                            actions.push(("keys.select".into(), this_key.clone()));
-                            if mods.alt {
-                                actions.push(("keys.toggleHold".into(), json!({})));
-                            } else {
-                                let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
-                                actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
-                            }
-                        } else if kresp.clicked() && mods.shift {
-                            // Shift+click: in or out of the selection.
-                            let mut p = this_key.clone();
-                            p["toggle"] = json!(true);
-                            actions.push(("keys.select".into(), p));
-                        } else if (kresp.clicked() || kresp.drag_started()) && !ks {
-                            let mut p = this_key.clone();
-                            p["add"] = json!(mods.shift);
-                            actions.push(("keys.select".into(), p));
-                        }
-                        if kresp.double_clicked() {
-                            // Edit the key's value (numeric properties), as After Effects does.
-                            if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
-                                actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
-                            } else {
-                                ui_actions.push(UiAct::SetTime(ct));
-                            }
-                        }
-                        crate::widgets::context_menu(&kresp, |ui| {
-                            for (lbl, cmd, params) in [
-                                ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
-                                ("-", "", json!({})),
-                                ("Copy", "keys.copy", json!({})),
-                                ("Paste", "keys.paste", json!({})),
-                                ("-", "", json!({})),
-                                ("Select Equal Keyframes", "keys.selectEqual", json!({})),
-                                ("Select Previous Keyframes", "keys.selectPrevious", json!({})),
-                                ("Select Following Keyframes", "keys.selectFollowing", json!({})),
-                                ("-", "", json!({})),
-                                ("Keyframe Interpolation…", "keys.interpolation", json!({})),
-                                ("Keyframe Velocity…", "keys.velocity", json!({})),
-                                ("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
-                                ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
-                                ("-", "", json!({})),
-                                ("Easy Ease", "keys.easyEase", json!({})),
-                                ("Easy Ease In", "keys.easyEaseIn", json!({})),
-                                ("Easy Ease Out", "keys.easyEaseOut", json!({})),
-                                ("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
-                                ("-", "", json!({})),
-                                ("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
-                                ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
-                                ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
-                                ("Select All Keyframes", "keys.selectAll", json!({})),
-                                ("Delete", "keys.delete", json!({})),
-                            ] {
-                                if lbl == "Select All Keyframes" {
-                                    // Keyframe colour labels.
-                                    ui.menu_button(crate::i18n::tr("Label"), |ui| {
-                                        for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
-                                            if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
-                                                if !ks {
-                                                    actions.push((
-                                                        "keys.select".into(),
-                                                        json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}),
-                                                    ));
-                                                }
-                                                actions.push(("keys.setLabel".into(), json!({"label": i})));
-                                                ui.close();
-                                            }
-                                        }
-                                    });
-                                }
-                                if lbl == "-" {
-                                    ui.separator();
-                                    continue;
-                                }
-                                if lbl == "Rove Across Time" && !prop.spatial {
-                                    continue;
-                                }
-                                if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
-                                    continue;
-                                }
-                                if ui.button(lbl).clicked() {
-                                    if !ks {
-                                        actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
-                                    }
-                                    actions.push((cmd.into(), params));
-                                    ui.close();
-                                }
-                            }
-                        });
-                        // Alt-drag the first or last key of a selected group: scale the group in time.
-                        if kresp.drag_started()
-                            && mods.alt
-                            && !mods.command
-                            && let Some(c) = app.session.active_comp_arc()
-                        {
-                            super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
-                        }
-                        // A plain drag moves the selected keys. The timeline follows it (below): this
-                        // widget's id changes with the key's time, so its own drag would end after
-                        // the first frame of movement.
-                        // (From where the button went down: a drag starts a few pixels later.)
-                        if kresp.drag_started()
-                            && !super::graph_tools::alt_scale_active(&ctx)
-                            && let Some(p) = ctx.input(|i| i.pointer.press_origin())
-                        {
-                            // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
-                            let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
-                            ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
-                        }
-                    }
+            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
+        }
+        if gresp.drag_stopped() {
+            ctx.data_mut(|d| d.remove::(acc_id));
+            ui_actions.push(UiAct::EndMerge);
+        }
+    }
+}
+
+/// The source extent behind the bar (striped past its ends).
+fn layer_source_extent(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect, lc: Color32) {
+    let tm = g.tl.tm;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source
+        && let Some(d) = layer.source.item().and_then(|i| app.session.project.item(i)).and_then(|i| i.duration())
+    {
+        let xs = tm.x(layer.start_time.seconds());
+        let xe = tm.x(layer.comp_time(d).seconds());
+        let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
+        gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
+        // The bar past the source's end (its last frame held) or before its start
+        // is striped, so the source's own span shows (#290). Time remapping
+        // decides what shows there itself.
+        if layer.props.get("timeRemap").is_none() {
+            for (side, part) in [
+                ("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
+                ("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
+            ] {
+                if part.width() >= 1.0 {
+                    stripes(&gp, part, lc);
+                    app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
                 }
             }
         }
+        slip_source_handles(app, ui, g, rc, ghost, bar);
     }
+}
+
+/// The bar's move body and trim edges, click selection.
+fn layer_bar_hits(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect) -> BarResps {
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
+    // Interactions: move body, trim edges.
+    let (body_r, in_r, out_r) = trim_zones(bar);
+    let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
+    let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
+    let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
+    app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
+    app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
+    if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
+        trim_cursor(&ctx);
+    }
+    if body.double_clicked()
+        && let Some(a) = double_click_action(layer)
+    {
+        actions.push(a);
+    }
+    let shift = ui.input(|i| i.modifiers.shift);
+    if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
+        // Shift-click: the layers from the last one clicked to this one, as in
+        // the layer outline.
+        actions.push(("layer.select".into(), json!({"layers": layers})));
+    } else {
+        if (body.clicked() || body.drag_started()) && !is_sel {
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
+        }
+        if (body.clicked() || body.drag_started()) && !shift {
+            set_anchor(&ctx, cid, layer.id);
+        }
+    }
+    layer_context_menu(&body, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
+    BarResps { body, lin, lout }
+}
+
+fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &egui::Response) -> bool {
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    let mods = ui.input(|i| i.modifiers);
+    if body.drag_started()
+        && is_sel
+        && mods.command
+        && mods.alt
+        && let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
+        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
+    {
+        ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
+    }
+    ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id)
+}
+
+/// Moving and trimming a layer bar by whole frames.
+fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) {
+    let BarResps { body, lin, lout } = bars;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    let drag_key = format!("bar-{}", layer.id.0);
+    for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
+        if kind == "move" && quick {
+            continue;
+        }
+        if resp.dragged() {
+            let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
+            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
+            acc += resp.drag_delta().x;
+            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
+            if frames != 0 {
+                acc -= (frames as f64 * fd * pps) as f32;
+                let d = frames as f64 * fd;
+                // Moving or trimming a selected layer acts on the whole
+                // selection; each layer trims from its own in/out point.
+                let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) };
+                let params = match kind {
+                    "move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
+                    "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
+                    _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
+                };
+                actions.push(("layer.timing".into(), params));
+            }
+            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
+        }
+        if resp.drag_stopped() {
+            ctx.data_mut(|d| d.remove::(egui::Id::unique(("bar-acc", layer.id.0, kind))));
+            ui_actions.push(UiAct::EndMerge);
+        }
+    }
+}
+
+fn layer_bar_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let comp = g.tl.comp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
+    super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
+}
+
+/// The group row's background and effect label bar; returns the name to draw.
+fn group_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) -> String {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let ri = rc.ri;
+    let RowKind::Group { uid, name, .. } = &rc.row.kind else { return String::new() };
+    let group = layer.props.find_group(*uid);
+    let shown = match group.and_then(|g| match &g.kind {
+        GroupKind::Effect { effect } => Some(effect.as_str()),
+        _ => None,
+    }) {
+        Some(effect) => crate::i18n::effects::group(effect, name),
+        None => name.clone(),
+    };
+    // Selected groups (an effect, a mask, a shape group…) highlight like selected
+    // property rows.
+    let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_group {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
+    // An effect's colour label (26.5): the same bar the Effect Controls header wears,
+    // on the row's left edge.
+    if let Some(lab) =
+        group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
+    {
+        let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
+        let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
+        lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
+        app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
+    }
+    shown
+}
+
+/// The group's twirl, effect switch and style eye.
+fn group_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, open, has_children, fx, eye, .. } = &rc.row.kind else { return };
+    if *has_children {
+        let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
+        if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
+            // Alt/Cmd-click twirls the whole tree under the group (as on a layer).
+            ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name);
+    }
+    if let Some(en) = fx {
+        let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
+        if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
+            actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
+    }
+    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(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::unique(("geye", uid)), Sense::click()).clicked() {
+            actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
+    }
+}
+
+/// The group name, or its rename field.
+fn group_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, shown: &str) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, .. } = &rc.row.kind else { return };
+    let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id()));
+    if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) {
+        let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0));
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field));
+        let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width()));
+        app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf);
+        if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
+            er.request_focus();
+            widgets::select_all(&ctx, er.id, &buf);
+        }
+        if er.lost_focus() {
+            if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name {
+                actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf})));
+            }
+            ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id()));
+        } else {
+            ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf)));
+        }
+    } else {
+        lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text);
+    }
+}
+
+/// Add / animate popups, Change Renderer and dash buttons.
+fn group_popups(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Group { uid, .. } = &rc.row.kind else { return };
+    text_anim_popups(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+    shape_add_popup(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+    if *uid == GEOM_BIT | layer.id.0 {
+        let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
+        let resp = ui
+            .interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
+            .on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
+        if resp.hovered() {
+            ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
+        }
+        app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
+        if resp.clicked() {
+            ui_actions.push(UiAct::ChangeRenderer);
+        }
+    }
+    dash_buttons(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+}
+
+/// Mask mode and inverted, inline.
+fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Group { uid, .. } = &rc.row.kind else { return };
+    // Mask mode + inverted inline.
+    if let Some(g) = layer.props.find_group(*uid)
+        && let GroupKind::Mask { mode, inverted, .. } = g.kind
+    {
+        let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
+        let pop = egui::Id::unique(("maskmode", uid));
+        if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        let opts: Vec = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
+        if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
+            actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
+        }
+        let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
+        if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
+            actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
+        }
+        lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
+    }
+}
+
+/// Group row click and double-click.
+fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let r = rc.r;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return };
+    let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
+    let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
+    app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
+    if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
+        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
+    }
+    if gr.double_clicked() {
+        if fx.is_some() {
+            // An effect's name: open it in Effect Controls (as in After Effects).
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
+            actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
+            actions.push(("window.panel".into(), json!({"panel": "EffectControls"})));
+        } else {
+            ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false));
+        }
+    }
+}
+
+fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let row = rc.row;
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let cy = rc.cy;
+    let ri = rc.ri;
+    let RowKind::Waveform { item, .. } = &rc.row.kind else { return };
+    lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt });
+    let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
+    lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text);
+    let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
+    gp.rect_filled(wr, 0.0, t.tl_bg);
+    app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
+    match super::waveform::summary(app, &ctx, ItemId(*item)) {
+        Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
+        None => {
+            gp.text(
+                pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
+                Align2::LEFT_CENTER,
+                "Building waveform…",
+                Tokens::ui(11.0),
+                t.text_faint,
+            );
+        }
+    }
+}
+
+/// The expression row's outline side: enable, pick whip and syntax warning.
+fn expr_controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let row = rc.row;
+    let layer = rc.layer;
+    let left = rc.left;
+    let cy = rc.cy;
+    let ri = rc.ri;
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_prop {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
+    lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
+    lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
+    // "=" enable switch and the property pick whip (AE's expression controls).
+    let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
+    let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
+    lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
+    let expr_col = t.expr_text;
+    lp.text(en_r.center(), Align2::CENTER_CENTER, if ex.enabled { "=" } else { "≠" }, Tokens::semibold(13.0), if ex.enabled { expr_col } else { t.text_dim });
+    app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
+    if en.clicked() {
+        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
+    }
+    let pw = pick_whip(
+        app,
+        ui,
+        &lp,
+        Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)),
+        (WHIP_EXPR, layer.id.0, *uid),
+        WhipText {
+            auto: &format!("timeline.prop.{uid}.pickWhip"),
+            label: "Expression pick whip",
+            hint: crate::i18n::tr("Expression pick whip: drag onto a property"),
+        },
+    );
+    if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
+        // Pressed while the expression is being edited (its text is buffered while
+        // the editor has the focus): the reference will go in at the cursor,
+        // replacing the selection (#284).
+        let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| {
+            let n = buf.chars().count();
+            let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
+                .and_then(|st| st.cursor.char_range())
+                .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
+            (buf, sel)
+        });
+        ctx.data_mut(|d| {
+            if let Some(into) = into {
+                d.insert_temp(whip_into_id(*uid), into);
+            } else {
+                d.remove::(whip_into_id(*uid));
+            }
+        });
+    }
+    // Syntax errors: warning in the outline (the expression is disabled).
+    if let Some(check) = app.session.expr_check
+        && let Err(e) = check(&ex.text)
+    {
+        let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
+        lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
+        let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
+    }
+}
+
+/// The Expression Language menu inserts at the cursor.
+fn expr_language_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    // ⓕ: the Expression Language menu inserts at the cursor.
+    let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
+    let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
+    lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
+    lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
+    app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
+    let mut picked: Option<&'static str> = None;
+    egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
+        for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
+            ui.menu_button(cat, |ui| {
+                widgets::menu_scroll(ui, false, |ui| {
+                    for (label, text) in items {
+                        if ui.button(label).clicked() {
+                            picked = Some(text);
+                            ui.close();
+                        }
+                    }
+                })
+            });
+        }
+    });
+    if let Some(text) = picked {
+        let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
+        let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
+            .and_then(|st| st.cursor.char_range())
+            .map(|r| r.primary.index.0)
+            .unwrap_or(cur.chars().count())
+            .min(cur.chars().count());
+        let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
+        let mut next = cur.clone();
+        next.insert_str(byte, text);
+        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
+        ctx.data_mut(|d| d.remove::(buf_id));
+    }
+}
+
+/// The expression editor in the time-graph area.
+fn expr_editor_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let rh = g.tl.rh;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let right_clip = g.right_clip;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let RowKind::Expr { uid, lines, .. } = &rc.row.kind else { return };
+    let expr_col = t.expr_text;
+    // The editor in the time-graph area.
+    ui.set_clip_rect(right_clip);
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
+    let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
+    let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
+    if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) {
+        // Asked for once (a pick whip put a reference in, or an Alt-click added the
+        // expression): editing starts in the expression as it is now.
+        ctx.data_mut(|d| d.remove::(expr_focus_id()));
+        buf = ex.text.clone();
+        let n = buf.chars().count();
+        let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
+        let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
+        st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
+        st.store(&ctx, edit_id);
+        ctx.memory_mut(|m| m.request_focus(edit_id));
+    }
+    // Settings ▸ Scripting & Expressions ▸ Expressions Editor.
+    let sp = app.session.prefs.scripting.clone();
+    let resp = super::expr_editor::editor(
+        ui,
+        edit_id,
+        &mut buf,
+        er,
+        super::expr_editor::EditorOpts { prefs: &sp, color: if ex.enabled { expr_col } else { t.text_dim }, rows: *lines },
+        &t,
+    );
+    app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
+    // Its bottom edge drags to set its height in whole lines, as in After Effects.
+    let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
+    let eresp = ui.interact(edge, egui::Id::unique(("expr-edge", uid)), Sense::drag());
+    app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
+    if eresp.hovered() || eresp.dragged() {
+        ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
+    }
+    if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
+        let n = ((py - r.min.y) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
+        app.ui.timeline.expr_lines.insert(*uid, n as usize);
+    }
+    let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
+    if commit {
+        resp.surrender_focus();
+    }
+    if resp.has_focus() && !commit {
+        ctx.data_mut(|d| d.insert_temp(buf_id, buf));
+    } else {
+        if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
+            actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
+        }
+        ctx.data_mut(|d| d.remove::(buf_id));
+    }
+}
+
+fn prop_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let ri = rc.ri;
+    let uid = &prop.uid;
+
+    app.auto.add(&format!("timeline.prop.{uid}.row"), r, &prop.name);
+    let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_prop {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
+}
+
+/// The keyframe navigator (previous, toggle, next).
+fn prop_key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let fr = g.tl.fr;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    // Keyframe navigator (in the A/V column, like AE).
+    // (in the Keys column when it is shown).
+    if prop.is_animated() && (vis.keys || vis.av) {
+        let lt = layer.layer_time(time);
+        let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some();
+        let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 };
+        let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0));
+        let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0));
+        let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0));
+        icons::paint(&lp, prev.shrink(2.0), Icon::ChevronLeft, t.text_dim);
+        icons::paint(&lp, next.shrink(2.0), Icon::ChevronRight, t.text_dim);
+        let kc = if at_key { t.keyframe_selected } else { Color32::TRANSPARENT };
+        lp.add(egui::Shape::convex_polygon(
+            vec![mid.center() + vec2(0.0, -5.0), mid.center() + vec2(5.0, 0.0), mid.center() + vec2(0.0, 5.0), mid.center() + vec2(-5.0, 0.0)],
+            kc,
+            Stroke::new(1.0, t.text_dim),
+        ));
+        if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
+            && let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
+        {
+            ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
+        }
+        if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
+            && let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
+        {
+            ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
+        }
+        if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
+            actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
+        }
+        app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
+    }
+}
+
+/// The stopwatch.
+fn prop_stopwatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let uid = &prop.uid;
+
+    // Stopwatch: Alt+click adds an expression; a press toggles the animation, and
+    // dragging on over other stopwatches gives them the same state (#284).
+    if !prop.static_only {
+        let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
+        let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
+        let col = if prop.is_animated() {
+            t.hot_text
+        } else if resp.hovered() {
+            t.text
+        } else {
+            t.text_dim
+        };
+        icons::paint(&lp, swr, Icon::Stopwatch, col);
+        app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
+        if ui.input(|i| i.modifiers.alt) {
+            if resp.clicked() {
+                actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
+                if prop.expr.is_none() {
+                    // Adding one: its editor opens, comes into view and takes the
+                    // keyboard with the new expression selected (#1072).
+                    app.ui.timeline.expr_closed.remove(uid);
+                    focus_expression(&ctx, *uid, [usize::MAX, 0]);
+                } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
+                    // Removing it: nothing is left to take the keyboard.
+                    ctx.data_mut(|d| d.remove::(expr_focus_id()));
+                }
+            }
+        } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
+            actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
+        } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
+            // Keyboard or accessibility activation.
+            actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
+        }
+    }
+}
+
+/// The property name; returns its x.
+fn prop_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) -> f32 {
+    let t = g.tl.t;
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let uid = &prop.uid;
+
+    let name_x = indent + 12.0;
+    // 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
+    let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
+    let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
+    let pname = if prop.has_expression() { format!("{base}  =") } else { base };
+    lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
+    let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
+    let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
+    app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
+    if name_resp.drag_started() {
+        // Drop on the Essential Graphics panel to expose the property.
+        egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
+    }
+    if name_resp.dragged() {
+        egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer).show(|ui| ui.label(&prop.name));
+    }
+    if name_resp.clicked() {
+        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
+    }
+    crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut *actions));
+    name_x
+}
+
+/// The value editors and the property pick whip.
+fn prop_value_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>, prop: &Property, name_x: f32) {
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let dim_hits = &mut g.dim_hits;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    let value = ectx.value(layer, prop);
+    let vx = (cw.switches + 4.0).max(name_x + 120.0);
+    let ve = ValueEdit::new(ui, &lp, layer, prop, pos2(vx, cy));
+    let dims = value_editor(app, ui, &ve, &value, &mut *actions);
+    if !dims.is_empty() {
+        dim_hits.push((*uid, dims));
+    }
+    // The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on
+    // a property it links this one to it, adding the expression if there is none.
+    if vis.parent && !prop.static_only {
+        pick_whip(
+            app,
+            ui,
+            &lp,
+            Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
+            (WHIP_PROP, layer.id.0, *uid),
+            WhipText {
+                auto: &format!("timeline.prop.{uid}.propertyPickWhip"),
+                label: "Property pick whip",
+                hint: crate::i18n::tr("Property pick whip: drag onto a property"),
+            },
+        );
+    }
+}
+
+/// The one-line expression hint when its editor is closed.
+fn prop_expression_hint(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let right_clip = g.right_clip;
+    let gp = g.gp.clone();
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    ui.set_clip_rect(right_clip);
+    // Expression text row hint.
+    if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
+        gp.text(pos2(graph_x0 + 8.0, cy), Align2::LEFT_CENTER, format!("= {}", e.text.lines().next().unwrap_or("")), Tokens::mono(11.0), t.expr_text);
+    }
+}
+
+/// Key click selection and interpolation shortcuts.
+fn key_select(g: &mut Rw, kh: &KeyHit<'_>) {
+    let KeyHit { k, ks, mods, ref kresp, ref this_key, .. } = *kh;
+    let actions = &mut g.tl.actions;
+    if kresp.clicked() && mods.command {
+        // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
+        actions.push(("keys.select".into(), this_key.clone()));
+        if mods.alt {
+            actions.push(("keys.toggleHold".into(), json!({})));
+        } else {
+            let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
+            actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
+        }
+    } else if kresp.clicked() && mods.shift {
+        // Shift+click: in or out of the selection.
+        let mut p = this_key.clone();
+        p["toggle"] = json!(true);
+        actions.push(("keys.select".into(), p));
+    } else if (kresp.clicked() || kresp.drag_started()) && !ks {
+        let mut p = this_key.clone();
+        p["add"] = json!(mods.shift);
+        actions.push(("keys.select".into(), p));
+    }
+}
+
+fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
+    let KeyHit { k, ct, ref kresp, .. } = *kh;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+
+    if kresp.double_clicked() {
+        // Edit the key's value (numeric properties), as After Effects does.
+        if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
+            actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
+        } else {
+            ui_actions.push(UiAct::SetTime(ct));
+        }
+    }
+}
+
+/// The keyframe right-click menu.
+fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
+    let KeyHit { k, ks, ref kresp, .. } = *kh;
+    let t = g.tl.t;
+    let actions = &mut g.tl.actions;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+
+    crate::widgets::context_menu(kresp, |ui| {
+        for (lbl, cmd, params) in [
+            ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
+            ("-", "", json!({})),
+            ("Copy", "keys.copy", json!({})),
+            ("Paste", "keys.paste", json!({})),
+            ("-", "", json!({})),
+            ("Select Equal Keyframes", "keys.selectEqual", json!({})),
+            ("Select Previous Keyframes", "keys.selectPrevious", json!({})),
+            ("Select Following Keyframes", "keys.selectFollowing", json!({})),
+            ("-", "", json!({})),
+            ("Keyframe Interpolation…", "keys.interpolation", json!({})),
+            ("Keyframe Velocity…", "keys.velocity", json!({})),
+            ("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
+            ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
+            ("-", "", json!({})),
+            ("Easy Ease", "keys.easyEase", json!({})),
+            ("Easy Ease In", "keys.easyEaseIn", json!({})),
+            ("Easy Ease Out", "keys.easyEaseOut", json!({})),
+            ("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
+            ("-", "", json!({})),
+            ("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
+            ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
+            ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
+            ("Select All Keyframes", "keys.selectAll", json!({})),
+            ("Delete", "keys.delete", json!({})),
+        ] {
+            if lbl == "Select All Keyframes" {
+                // Keyframe colour labels.
+                ui.menu_button(crate::i18n::tr("Label"), |ui| {
+                    for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
+                        if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
+                            if !ks {
+                                actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
+                            }
+                            actions.push(("keys.setLabel".into(), json!({"label": i})));
+                            ui.close();
+                        }
+                    }
+                });
+            }
+            if lbl == "-" {
+                ui.separator();
+                continue;
+            }
+            if lbl == "Rove Across Time" && !prop.spatial {
+                continue;
+            }
+            if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
+                continue;
+            }
+            if ui.button(lbl).clicked() {
+                if !ks {
+                    actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
+                }
+                actions.push((cmd.into(), params));
+                ui.close();
+            }
+        }
+    });
+}
+
+/// Starts a key drag (or Alt-scale).
+fn key_drag_begin(app: &mut EffectcraftApp, g: &Rw, kh: &KeyHit<'_>) {
+    let KeyHit { ct, ks, mods, ref kresp, .. } = *kh;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let sel_keys = &g.sel_keys;
+    // Alt-drag the first or last key of a selected group: scale the group in time.
+    if kresp.drag_started()
+        && mods.alt
+        && !mods.command
+        && let Some(c) = app.session.active_comp_arc()
+    {
+        super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
+    }
+    // A plain drag moves the selected keys. The timeline follows it (below): this
+    // widget's id changes with the key's time, so its own drag would end after
+    // the first frame of movement.
+    // (From where the button went down: a drag starts a few pixels later.)
+    if kresp.drag_started()
+        && !super::graph_tools::alt_scale_active(&ctx)
+        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
+    {
+        // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
+        let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
+        ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
+    }
+}
+
+/// Paints one keyframe marker; returns its hit rect.
+fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe, x: f32, ks: bool) -> Rect {
+    let t = g.tl.t;
+    let gp = g.gp.clone();
+    let cy = rc.cy;
+    let icon = k.icon();
+    let c = pos2(x, cy);
+    // Labelled keys take their label colour (brighter when selected).
+    let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
+        Some(l) => {
+            let c = t.label(*l);
+            if ks { c } else { c.gamma_multiply(0.75) }
+        }
+        None if ks => t.keyframe_selected,
+        None => t.keyframe,
+    };
+    if k.roving {
+        // Roving keys: a small dot (their time follows their neighbours).
+        gp.circle_filled(c, 2.5, kcol);
+    } else {
+        icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
+    }
+    Rect::from_center_size(c, vec2(12.0, 14.0))
+}
+
+/// The key's hit target and automation ids.
+fn key_hit(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &Rw,
+    rc: &RowCtx<'_>,
+    prop: &Property,
+    k: &effectcraft_engine::keyframe::Keyframe,
+    kr: Rect,
+) -> egui::Response {
+    let fr = g.tl.fr;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+    let ct = layer.comp_time(k.time);
+    let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
+    app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
+    // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
+    app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
+    kresp
+}
+
+/// Right-click menus on the empty outline and graph areas.
+fn background_menus(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let full_clip = g.full_clip;
+    let empty_rect = g.empty_rect;
+    let empty = g.empty.clone();
+    let outline_empty = g.outline_empty.clone();
     ui.set_clip_rect(full_clip);
     // The background responses sit below rows, bars and editors, preserving their menus.
     if !graph_on {
@@ -3231,6 +4195,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         });
     }
+}
+
+/// Click and marquee-select on the empty outline.
+fn outline_marquee(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let actions = &mut g.tl.actions;
+    let outline_rect = g.outline_rect;
+    let lp = g.lp.clone();
+    let outline_empty = g.outline_empty.clone();
+    let hit_rows = &mut g.hit_rows;
     if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
         actions.push(("layer.select".into(), json!({"layers": []})));
     }
@@ -3255,6 +4231,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(("layer.select".into(), json!({"layers": layers, "add": ui.input(|i| i.modifiers.shift || i.modifiers.command)})));
     }
     // Pick whips: a line follows the pointer; releasing over a row links to it (an expression's
+}
+
+/// A pick whip in flight: its line, the target and the link on release.
+fn pick_whip_drag(g: &mut Rw) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    let dim_hits = &mut g.dim_hits;
     // or a property's, also over a property in Effect Controls).
     if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::(pick_whip_id())) {
         let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start);
@@ -3314,16 +4300,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     // Alt-drag key group scaling (follows the pointer until release).
+}
+
+fn alt_scale_and_graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let gp = g.gp.clone();
     if let Some(c) = app.session.active_comp_arc() {
-        super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions);
+        super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut *actions);
     }
     // Graph editor.
     if graph_on {
-        super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
+        super::graph::show(
+            app,
+            ui,
+            &gp,
+            super::graph::GraphView { comp: &comp, ectx, tm },
+            Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max),
+            &mut *actions,
+        );
     }
     // A plain click on empty track area (beside a layer's bar, on a property row's empty span or
     // below the last row) deselects the layers and their keys and properties, as in After
-    // Effects; Shift / Cmd keep the selection. Drag: box-select keys.
+}
+
+/// Click / marquee on empty track area.
+fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let gp = g.gp.clone();
+    let empty = g.empty.clone();
     if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
         actions.push(("layer.select".into(), json!({"layers": []})));
     }
@@ -3335,7 +4347,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
         ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-box"), br));
     }
-    // Layer reorder drag: a line where the layers will land; the release moves them there.
+}
+
+/// Layer reorder drag: a line where the layers land.
+fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
     if let Some(moving) = ctx.data(|d| d.get_temp::>(layer_drag_id())) {
         let dropped = ctx.data_mut(|d| d.remove_temp::(layer_drop_id())).unwrap_or(false);
         let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
@@ -3366,7 +4390,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
-    // until the button is released, wherever the bar's row has scrolled to.
+}
+
+/// Quick Offset of layer bars follows the pointer.
+fn quick_offset_follow(g: &mut Rw) {
+    let t = g.tl.t;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
     if let Some(mut q) = ctx.data(|d| d.get_temp::(quick_bar_id())) {
         let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos()));
         match ptr.filter(|_| down) {
@@ -3388,7 +4421,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     // Keyframe drag: the selected keys move by whole frames with the pointer until it is
     // released (one undo step); Shift snaps the grabbed key to the current time, the work area
-    // and composition markers.
+}
+
+/// The key drag moves the selected keys by whole frames.
+fn key_drag_follow(app: &mut EffectcraftApp, g: &mut Rw) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
     if let Some(mut kd) = ctx.data(|d| d.get_temp::(key_drag_id())) {
         let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift));
         match ptr.filter(|_| down) {
@@ -3399,7 +4444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     // not the moving keys themselves.
                     let moving: Vec =
                         app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
-                    let mut cands = snap_candidates(&comp, &rows);
+                    let mut cands = snap_candidates(&comp, rows);
                     cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9));
                     cands.push(time.seconds());
                     target = snap_time(&cands, target, 8.0 / pps);
@@ -3430,13 +4475,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// Releasing the key marquee selects the keys inside it.
+fn key_box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let rh = g.tl.rh;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let empty = g.empty.clone();
     if empty.drag_stopped()
         && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("tl-box")))
     {
         ctx.data_mut(|d| d.remove::(egui::Id::unique("tl-box")));
         let mut keys = vec![];
         let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y;
-        for row in &rows {
+        for row in rows.iter() {
             let cy = yy + rh / 2.0;
             yy += row_height(row, rh);
             if let RowKind::Prop { uid } = row.kind
@@ -3461,8 +4518,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             actions.push(("keys.select".into(), json!({"keys": keys, "add": mods.shift, "selectProperties": true})));
         }
     }
+}
 
-    // CTI.
+fn current_time_indicator(app: &mut EffectcraftApp, g: &Rw) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let p = g.tl.p.clone();
     let cx = tm.x(time.seconds());
     if cx >= graph_x0 && cx <= rect.max.x {
         p.line_segment([pos2(cx, ruler.min.y + 12.0), pos2(cx, rows_rect.max.y)], Stroke::new(1.0, t.cti));
@@ -3476,9 +4542,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         p.add(egui::Shape::convex_polygon(head, t.cti, Stroke::NONE));
         app.auto.add("timeline.cti", Rect::from_center_size(pos2(cx, ruler.center().y), vec2(12.0, 20.0)), "Current time indicator");
     }
-    let _ = graph_rect;
+}
 
-    // Footer.
+/// The footer: scroll bars, the switches / modes toggle and render time.
+fn footer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) -> Rect {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let footer_h = g.tl.footer_h;
+    let overflow = g.tl.overflow;
+    let oscroll = g.tl.oscroll;
+    let graph_x0 = g.tl.graph_x0;
+    let rows_rect = g.tl.rows_rect;
+    let max_scroll = g.tl.max_scroll;
+    let p = g.tl.p.clone();
     let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
     p.rect_filled(footer, 0.0, t.panel_bg);
     p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator));
@@ -3514,6 +4590,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         t.text_faint,
     );
     // Zoom slider.
+    footer
+}
+
+/// The zoom buttons and slider.
+fn footer_zoom(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, footer: Rect) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let fit_pps = g.tl.fit_pps;
+    let pps = g.tl.pps;
+    let tm = g.tl.tm;
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
     let zs = Rect::from_min_max(pos2(graph_x0 + 30.0, footer.center().y - 2.0), pos2((graph_x0 + 230.0).min(rect.max.x - 30.0), footer.center().y + 2.0));
     // Zoom out / zoom in buttons either side of the slider: the `-` / `=` steps. Everything here
     // zooms around the CTI and sets the one time zoom the layer bars and the Graph Editor share.
@@ -3545,16 +4635,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let f = ((pt.x - zs.min.x) / zs.width().max(1.0)).clamp(0.0, 1.0) as f64;
         set_zoom(&mut app.ui.timeline, &comp, fit_pps * (maxpps / fit_pps).powf(f), fit_pps, cti, cti_px);
     }
+}
 
+fn comp_markers_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let bin = g.tl.bin;
+    let comp = g.tl.comp.clone();
     // Composition markers in the ruler; protected regions shade the layer rows.
     let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(bin.min.x, ruler.max.y));
     let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
     super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
     super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip);
+}
 
-    // Spacebar held: the Hand tool. Dragging pans the rows vertically as well as scrolling the
-    // time graph in time when zoomed in (registered last, so it takes the drag from the bars,
-    // keys and markers there).
+/// Spacebar held: the Hand tool.
+fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let graph_x1 = g.tl.graph_x1;
+    let pps = g.tl.pps;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let max_scroll = g.tl.max_scroll;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
     if super::space_hand(&ctx) {
         let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y));
         let hand = ui.interact(area, egui::Id::unique("tl-hand"), Sense::drag());
@@ -3572,184 +4680,130 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
 
-    // Drop targets: effects and animation presets from Effects & Presets go on the layer under the
-    // pointer. Footage and
-    // comps from the Project panel and files from the Media Browser go where they are dropped: a
-    // line shows the place in the stack, and over the time graph a marker shows the In point too
-    // (Shift: at the current time).
-    if let Some(payload) = egui::DragAndDrop::payload::(&ctx)
-        && !matches!(*payload, crate::panels::DragPayload::Property { .. })
-        && let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
-    {
-        let released = ctx.input(|i| i.pointer.any_released());
-        // Alt/Option-drag from the Project panel replaces the source of the layer under the
-        // pointer (After Effects). The shortcut replaces every selected layer instead.
-        let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_));
-        if alt_item {
-            if let crate::panels::DragPayload::Item(id) = payload.as_ref() {
-                let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
-                if let Some(l) = target
-                    && replace_source_target(app, cid, ItemId(*id), l, &comp)
-                    && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
-                {
-                    let lit = span.intersect(rows_rect);
-                    let rp = p.with_clip_rect(rows_rect);
-                    rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
-                    rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
-                    app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source");
-                    if released {
-                        actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
-                    }
-                }
-            }
-        } else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
-            let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
-            // The layer the effect would land on (its row and its open properties) is the one
-            // that lights up, not the whole timeline.
-            if let Some(l) = target
-                && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
-            {
-                let lit = span.intersect(rows_rect);
-                let rp = p.with_clip_rect(rows_rect);
-                rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
-                rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
-                app.auto.add("timeline.dropTarget", lit, "Drop target layer");
-            }
-            if released && let Some(l) = target {
-                match payload.as_ref() {
-                    crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))),
-                    crate::panels::DragPayload::Preset { preset, .. } => {
-                        let (id, params) = preset.apply_to(l.0);
-                        actions.push((id.into(), params));
-                    }
-                    _ => {}
-                }
-            }
-        } else {
-            let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
-            let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
-            let index = match at.and_then(|i| layer_rows.get(i)) {
-                Some((_, l)) => comp.index_of(*l).unwrap_or(1),
-                None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
-            };
-            let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
-                let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
-                comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
-            });
+/// Alt-drag of an item over a layer: replace its source.
+fn drop_replace_source(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let cid = g.tl.cid;
+    let rows_rect = g.tl.rows_rect;
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    if let crate::panels::DragPayload::Item(id) = payload.as_ref() {
+        let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
+        if let Some(l) = target
+            && replace_source_target(app, cid, ItemId(*id), l, &comp)
+            && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
+        {
+            let lit = span.intersect(rows_rect);
             let rp = p.with_clip_rect(rows_rect);
-            rp.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
-            if let Some(s) = start {
-                let x = tm.x(s.seconds());
-                rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
-                // The In point's time in a tag beside the marker.
-                let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
-                let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
-                rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
-                rp.galley(tag.min, g, Color32::WHITE);
-            }
+            rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
+            rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
+            app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source");
             if released {
-                let time = json!(start.map(Tick::seconds));
-                match payload.as_ref() {
-                    crate::panels::DragPayload::Item(id) => {
-                        // Insert each selected item at the drop point, in selection order.
-                        for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
-                            actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
-                        }
-                    }
-                    crate::panels::DragPayload::Files(paths) => {
-                        actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
-                    }
-                    crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {}
-                }
+                actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
             }
         }
-        if released {
-            egui::DragAndDrop::clear_payload(&ctx);
-        }
     }
+}
 
-    // Apply.
-    for a in ui_actions {
-        match a {
-            UiAct::ToggleLayer(id, all) => {
-                let tl = &mut app.ui.timeline;
-                // Twirled by hand, the layer shows its whole tree again.
-                tl.reveal.clear();
-                tl.layer_reveal.remove(&id);
-                tl.full_groups.remove(&id);
-                let open = !tl.open_layers.contains(&id);
-                if open {
-                    tl.open_layers.insert(id);
-                } else {
-                    tl.open_layers.remove(&id);
-                }
-                if all && let Some(l) = comp.layer(LayerId(id)) {
-                    let mut uids = vec![];
-                    walk_groups(&l.props, &mut uids);
-                    for u in uids {
-                        if open {
-                            tl.open_groups.insert(u);
-                        } else {
-                            tl.open_groups.remove(&u);
-                        }
-                    }
-                }
-            }
-            UiAct::ToggleGroup(layer, uid, all) => {
-                let tl = &mut app.ui.timeline;
-                tl.toggle_group(layer, uid);
-                let open = tl.open_groups.contains(&uid);
-                if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) {
-                    let mut uids = vec![];
-                    walk_groups(g, &mut uids);
-                    for u in uids {
-                        if open {
-                            tl.open_groups.insert(u);
-                        } else {
-                            tl.open_groups.remove(&u);
-                        }
-                    }
-                }
-            }
-            UiAct::SetTime(tt) => app.session.set_time(tt),
-            UiAct::EndMerge => app.session.history.merge_key = None,
-            UiAct::ChangeRenderer => {
-                if super::dialogs::open_comp_settings(app).is_ok() {
-                    app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
-                }
+/// Effects and presets drop onto the layer under the pointer.
+fn drop_on_layer(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let rows_rect = g.tl.rows_rect;
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
+    // The layer the effect would land on (its row and its open properties) is the one
+    // that lights up, not the whole timeline.
+    if let Some(l) = target
+        && let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
+    {
+        let lit = span.intersect(rows_rect);
+        let rp = p.with_clip_rect(rows_rect);
+        rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
+        rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
+        app.auto.add("timeline.dropTarget", lit, "Drop target layer");
+    }
+    if released && let Some(l) = target {
+        match payload.as_ref() {
+            crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))),
+            crate::panels::DragPayload::Preset { preset, .. } => {
+                let (id, params) = preset.apply_to(l.0);
+                actions.push((id.into(), params));
             }
+            _ => {}
         }
     }
-    if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
-        app.ui.viewer.property_interacting = true;
-        ctx.request_repaint();
+}
+
+/// Footage and files drop into the stack: a line shows the place.
+fn drop_insert_layers(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let hit_rows = &mut g.hit_rows;
+    let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
+    let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
+    let index = match at.and_then(|i| layer_rows.get(i)) {
+        Some((_, l)) => comp.index_of(*l).unwrap_or(1),
+        None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
+    };
+    let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
+        let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
+        comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
+    });
+    let rp = p.with_clip_rect(rows_rect);
+    rp.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
+    if let Some(s) = start {
+        let x = tm.x(s.seconds());
+        rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
+        // The In point's time in a tag beside the marker.
+        let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
+        let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
+        rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
+        rp.galley(tag.min, g, Color32::WHITE);
     }
-    for (id, params) in actions {
-        if id == "__endMerge" {
-            app.session.history.merge_key = None;
-            continue;
-        }
-        if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
-            app.ui.status = e;
+    if released {
+        let time = json!(start.map(Tick::seconds));
+        match payload.as_ref() {
+            crate::panels::DragPayload::Item(id) => {
+                // Insert each selected item at the drop point, in selection order.
+                for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
+                    actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
+                }
+            }
+            crate::panels::DragPayload::Files(paths) => {
+                actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
+            }
+            crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {}
         }
     }
 }
 
 /// The optional per-layer cells: Comment (double-click to edit), In / Out / Duration (drag to
 /// change by frames), Stretch (click: Time Stretch dialog).
-#[allow(clippy::too_many_arguments)]
 fn layer_cells(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     lp: &egui::Painter,
     cw: &Cols,
     comp: &Comp,
-    layer: &Layer,
-    r: Rect,
-    cy: f32,
+    rc: &RowCtx<'_>,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (layer, r, cy) = (rc.layer, rc.r, rc.cy);
     let t = app.tokens;
     let ctx = ui.ctx().clone();
     let lid = layer.id.0;
@@ -3863,17 +4917,16 @@ fn find_group(g: &PropGroup, uid: u64) -> Option<&PropGroup> {
 }
 
 /// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair).
-#[allow(clippy::too_many_arguments)]
 fn dash_buttons(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") {
         return;
     }
@@ -3926,17 +4979,16 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
 
 /// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the
 /// selected shape group, else to the layer's contents.
-#[allow(clippy::too_many_arguments)]
 fn shape_add_popup(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) {
         return;
     }
@@ -3969,17 +5021,16 @@ fn shape_add_popup(
 }
 
 /// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
-#[allow(clippy::too_many_arguments)]
 fn text_anim_popups(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     use effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS;
     if !matches!(layer.source, LayerSource::Text) {
         return;
@@ -4160,7 +5211,6 @@ impl LayerMenuCaps {
 /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part
 /// of the selection keeps the selection and the menu acts on all of it; right-clicking an
 /// unselected layer selects only that layer.
-#[allow(clippy::too_many_arguments)]
 fn layer_context_menu(
     resp: &egui::Response,
     comp: &Comp,
@@ -4337,200 +5387,270 @@ fn fmt_num(v: f64, d: usize) -> String {
     format!("{v:.d$}")
 }
 
-/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
-/// multi-dimension value's fields, one per dimension (none for other values).
-fn value_editor(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    layer: &Layer,
-    prop: &Property,
-    value: &Value,
+/// What the property value editors share: where to draw, what they edit, and how a change is
+/// worded as a `prop.set` command.
+struct ValueEdit<'a> {
+    p: &'a egui::Painter,
+    layer: &'a Layer,
+    prop: &'a Property,
     at: Pos2,
-    actions: &mut Vec<(String, serde_json::Value)>,
-) -> Vec {
-    let t = app.tokens;
-    let uid = prop.uid;
-    let mut dims = vec![];
-    let merge = format!("scrub-{uid}");
-    // With several layers selected the edit applies to all of them (#491): a scrub by the same
-    // change, a typed value as it is.
-    let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
-    // Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
-    // selected layers by selection order, the first unchanged and the last by all of it.
-    let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
-    let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
+    uid: u64,
+    merge: String,
+    scrub: bool,
+    proportional: bool,
+}
+
+impl<'a> ValueEdit<'a> {
+    /// The editor of `prop` on `layer` at `at`; `ui` says whether a scrub is under way.
+    fn new(ui: &egui::Ui, p: &'a egui::Painter, layer: &'a Layer, prop: &'a Property, at: Pos2) -> Self {
+        let uid = prop.uid;
+        // With several layers selected the edit applies to all of them (#491): a scrub by the same
+        // change, a typed value as it is.
+        let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
+        // Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
+        // selected layers by selection order, the first unchanged and the last by all of it.
+        let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
+        Self { p, layer, prop, at, uid, merge: format!("scrub-{uid}"), scrub, proportional }
+    }
+
+    /// Queues the `prop.set` of one edit.
+    fn set(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value) {
         actions.push((
             "prop.set".into(),
-            json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub, "proportional": proportional}),
+            json!({"layer": self.layer.id.0, "prop": self.uid, "value": v, "merge": self.merge, "selected": true, "offset": self.scrub, "proportional": self.proportional}),
         ))
-    };
-    let is_3d = layer.is_3d();
-    let mut x = at.x;
-    let y = at.y - 9.0;
+    }
+}
+
+/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
+/// multi-dimension value's fields, one per dimension (none for other values).
+fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, value: &Value, actions: &mut Vec<(String, serde_json::Value)>) -> Vec {
     match value {
-        Value::Scalar(v) => {
-            let (min, max, dec, speed) = match &prop.ui {
-                ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
-                ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
-                _ => (-1e9, 1e9, 1, 1.0),
-            };
-            if matches!(prop.ui, ParamUi::Angle) {
-                let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
-                app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
-                if let Some(nv) = nv {
-                    set(actions, json!(nv));
-                }
-            } else {
-                // A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
-                let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
-                let suffix = if pct { "%" } else { "" };
-                let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-                if let Some(nv) = nv {
-                    set(actions, json!(nv));
-                }
-            }
-        }
-        Value::Vec2(_) | Value::Vec3(_) => {
-            let c = value.components();
-            let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
-            let pct = matches!(prop.ui, ParamUi::Percent);
-            // Mask Feather: two linked values, never negative (as in After Effects, #203).
-            let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
-            let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
-            // Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
-            let linkable = feather || (pct && prop.match_id == "scale");
-            let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
-            if linkable {
-                let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
-                let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
-                icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
-                app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
-                if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
-                    app.ui.timeline.unlinked.insert(uid);
-                }
-                x += 18.0;
-            }
-            for d in 0..n.min(c.len()) {
-                let suffix = if pct && d + 1 == n { "%" } else { "" };
-                let (r, nv, _) =
-                    widgets::hot_number_at(ui, pos2(x, y), egui::Id::unique(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
-                dims.push(r);
-                if let Some(nv) = nv {
-                    let mut nc = c.clone();
-                    // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
-                    let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
-                    let from = if shown == 1 { d } else { 0 };
-                    for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
-                        if let Some(slot) = nc.get_mut(from + e) {
-                            *slot = v;
-                        }
-                    }
-                    set(actions, json!(nc));
-                    // Typed into these components only: other selected layers keep the rest of
-                    // their own value (#1434).
-                    if let Some((_, a)) = actions.last_mut() {
-                        a["dims"] = json!((from..from + shown).collect::>());
-                    }
-                }
-                x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
-                if d + 1 < n {
-                    p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text);
-                    x += 6.0;
-                }
-            }
-        }
-        Value::Color(c) => {
-            let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
-            let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-            let pop = egui::Id::unique(("cpop", uid));
-            if resp.clicked() {
-                widgets::open_popup(ui, pop);
-            }
-            // The eyedropper after the swatch samples the Composition panel (shape Fill and
-            // Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
-            let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
-            let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
-            if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
-                .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
-                .clicked()
-            {
-                super::effect_controls::toggle_pick(app, "color", layer, prop);
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
-            let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
-            if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
-                set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
-            }
-        }
-        Value::Bool(b) => {
-            let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
-            if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
-                set(actions, json!(!b));
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-        }
-        Value::Enum(i) => {
-            // Text Path Options ▸ Path lists the layer's masks.
-            let mask_opts: Option> = layer
-                .props
-                .prop("text/pathOptions/path")
-                .filter(|q| q.uid == uid)
-                .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
-            let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
-            if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
-                let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
-                let label = options.get(*i as usize).cloned().unwrap_or_default();
-                let pop = egui::Id::unique(("epop", uid));
-                if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
-                    widgets::open_popup(ui, pop);
-                }
-                app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-                if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) {
-                    set(actions, json!(ni));
-                }
-            } else {
-                p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
-            }
-        }
+        Value::Scalar(_) => scalar_editor(app, ui, ve, actions, value),
+        Value::Vec2(_) | Value::Vec3(_) => return vector_editor(app, ui, ve, actions, value),
+        Value::Color(_) => color_editor(app, ui, ve, actions, value),
+        Value::Bool(_) => bool_editor(app, ui, ve, actions, value),
+        Value::Enum(_) => enum_editor(app, ui, ve, actions, value),
+        Value::Layer(_) => layer_editor(app, ui, ve, actions, value),
+        Value::Gradient(_) => gradient_editor(app, ui, ve, actions),
         Value::Path(_) => {
-            p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), t.hot_text);
+            ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), app.tokens.hot_text);
         }
         Value::Text(_) => {}
-        Value::Layer(l) => {
-            // Layer references (a light's Source, a Displacement Map's layer…) pick from the
-            // comp's layers, as in Effect Controls; the choice is written through the same
-            // undoable prop.set as the other value arms.
-            let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
-            if let Some(comp) = app.session.active_comp_arc()
-                && let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
-            {
-                set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-        }
-        Value::Gradient(_) => {
-            // Opens the Gradient Editor at the current time (#612).
-            let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
-            let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
-            if resp.hovered() {
-                ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-            if resp.clicked() {
-                actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
-            }
-        }
         Value::Str(s) => {
-            p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), t.text_dim);
+            ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), app.tokens.text_dim);
+        }
+    }
+    Vec::new()
+}
+
+/// A number: scrubbable hot text, or the angle field.
+fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Scalar(v) = value else { return };
+    let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let (x, y) = (at.x, at.y - 9.0);
+    let (min, max, dec, speed) = match &prop.ui {
+        ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
+        ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
+        _ => (-1e9, 1e9, 1, 1.0),
+    };
+    if matches!(prop.ui, ParamUi::Angle) {
+        let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t);
+        app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
+        app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
+        if let Some(nv) = nv {
+            ve.set(actions, json!(nv));
+        }
+    } else {
+        // A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
+        let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
+        let suffix = if pct { "%" } else { "" };
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x, y),
+            egui::Id::unique(("v", uid, 0)),
+            *v,
+            &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix },
+            &t,
+        );
+        app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+        if let Some(nv) = nv {
+            ve.set(actions, json!(nv));
+        }
+    }
+}
+
+/// A multi-dimension value: one hot-text field per dimension, with the link chain. Returns their rects.
+fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec {
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let (mut x, y) = (at.x, at.y - 9.0);
+    let is_3d = layer.is_3d();
+    let mut dims = vec![];
+    let c = value.components();
+    let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
+    let pct = matches!(prop.ui, ParamUi::Percent);
+    // Mask Feather: two linked values, never negative (as in After Effects, #203).
+    let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
+    let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
+    // Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
+    let linkable = feather || (pct && prop.match_id == "scale");
+    let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
+    if linkable {
+        let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
+        let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
+        icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
+        app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
+        if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
+            app.ui.timeline.unlinked.insert(uid);
+        }
+        x += 18.0;
+    }
+    for d in 0..n.min(c.len()) {
+        let suffix = if pct && d + 1 == n { "%" } else { "" };
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x, y),
+            egui::Id::unique(("v", uid, d)),
+            c[d],
+            &widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix },
+            &t,
+        );
+        app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
+        dims.push(r);
+        if let Some(nv) = nv {
+            let mut nc = c.clone();
+            // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
+            let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
+            let from = if shown == 1 { d } else { 0 };
+            for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
+                if let Some(slot) = nc.get_mut(from + e) {
+                    *slot = v;
+                }
+            }
+            ve.set(actions, json!(nc));
+            // Typed into these components only: other selected layers keep the rest of
+            // their own value (#1434).
+            if let Some((_, a)) = actions.last_mut() {
+                a["dims"] = json!((from..from + shown).collect::>());
+            }
+        }
+        x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
+        if d + 1 < n {
+            p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text);
+            x += 6.0;
         }
     }
     dims
 }
 
+/// A colour: swatch, eyedropper and picker.
+fn color_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Color(c) = value else { return };
+    let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
+    let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+    let pop = egui::Id::unique(("cpop", uid));
+    if resp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    // The eyedropper after the swatch samples the Composition panel (shape Fill and
+    // Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
+    let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
+    let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
+    if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
+        .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
+        .clicked()
+    {
+        super::effect_controls::toggle_pick(app, "color", layer, prop);
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
+    let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
+    if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
+        ve.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
+    }
+}
+
+/// A checkbox.
+fn bool_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Bool(b) = value else { return };
+    let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
+    if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
+        ve.set(actions, json!(!b));
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+}
+
+/// A popup (or the number when the property has no options).
+fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Enum(i) = value else { return };
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Text Path Options ▸ Path lists the layer's masks.
+    let mask_opts: Option> = layer
+        .props
+        .prop("text/pathOptions/path")
+        .filter(|q| q.uid == uid)
+        .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
+    let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
+    if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
+        let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
+        let label = options.get(*i as usize).cloned().unwrap_or_default();
+        let pop = egui::Id::unique(("epop", uid));
+        if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+        if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) {
+            ve.set(actions, json!(ni));
+        }
+    } else {
+        p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
+    }
+}
+
+/// A layer reference picker.
+fn layer_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Layer(l) = value else { return };
+    let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Layer references (a light's Source, a Displacement Map's layer…) pick from the
+    // comp's layers, as in Effect Controls; the choice is written through the same
+    // undoable prop.set as the other value arms.
+    let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
+    if let Some(comp) = app.session.active_comp_arc()
+        && let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
+    {
+        ve.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+}
+
+/// Opens the Gradient Editor at the current time (#612).
+fn gradient_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>) {
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Opens the Gradient Editor at the current time (#612).
+    let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
+    let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
+    if resp.hovered() {
+        ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+    if resp.clicked() {
+        actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
+    }
+}
+
 #[cfg(test)]
 mod tests;
diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs
index f1c5b8b25..3edde09df 100644
--- a/crates/ui-egui/src/panels/tracker.rs
+++ b/crates/ui-egui/src/panels/tracker.rs
@@ -115,6 +115,32 @@ fn analyze_glyph(p: &egui::Painter, r: Rect, forward: bool, frame: bool, stop: b
     }
 }
 
+/// The two button rows at `y`: Track Camera / Warp Stabilizer, then Track Motion / Stabilize Motion.
+fn top_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, x0: f32, y: f32, half: f32, source: Option) {
+    let can_track = app.session.active_comp().is_some() && source.is_some();
+    // Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
+    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
+    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
+    let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
+    let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
+    if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
+        run(app, ctx, "track.camera", json!({}));
+    }
+    let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
+    if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
+        run(app, ctx, "track.warpStabilizer", json!({}));
+    }
+    let y = y + 28.0;
+    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
+    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
+    if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
+        run(app, ctx, "track.motion", src_p.clone());
+    }
+    if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
+        run(app, ctx, "track.stabilize", src_p.clone());
+    }
+}
+
 /// The Tracker panel: the camera/warp and motion/stabilize buttons, Motion Source and Current
 /// Track pickers, the track type and its channels, Motion Target with Edit Target and Options, the
 /// four Analyze buttons (the one whose direction is running offers Stop), Reset and Apply, and the
@@ -132,33 +158,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let w = (rect.width() - 20.0).max(120.0);
     let half = (w - 6.0) / 2.0;
     let mut y = rect.min.y + 10.0;
-    let has_comp = app.session.active_comp().is_some();
     let source = motion_source(app);
     let cur = current(app);
-    let can_track = has_comp && source.is_some();
 
-    // Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
-    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
-    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
-    let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
-    let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
-    if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
-        run(app, &ctx, "track.camera", json!({}));
-    }
-    let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
-    if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
-        run(app, &ctx, "track.warpStabilizer", json!({}));
-    }
-    y += 28.0;
-    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
-    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
-    if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
-        run(app, &ctx, "track.motion", src_p.clone());
-    }
-    if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
-        run(app, &ctx, "track.stabilize", src_p.clone());
-    }
-    y += 34.0;
+    top_buttons(app, ui, &ctx, x0, y, half, source);
+    y += 62.0;
     let Some(comp) = app.session.active_comp_arc() else {
         p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("Open a composition to track motion."), Tokens::ui(12.0), t.text_faint);
         return;
diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs
index a9770d9ed..bcae99bc5 100644
--- a/crates/ui-egui/src/panels/viewer.rs
+++ b/crates/ui-egui/src/panels/viewer.rs
@@ -72,6 +72,52 @@ thread_local! {
 /// (move/scale/rotate/anchor/camera/shape/mask/guide edits, picks, marquees); failures worth showing go to
 /// `app.ui.status`, while per-frame drag updates ignore errors so a gesture is not aborted mid-drag. With no active
 /// composition it draws `empty_state` and returns.
+/// What the passes of one viewer frame share (see [`show`]).
+struct Vw<'a> {
+    t: Tokens,
+    cid: effectcraft_engine::project::ItemId,
+    nav: Rect,
+    bar: Rect,
+    err_bar: Rect,
+    err_h: f32,
+    pasteboard: Color32,
+    area: Rect,
+    outer: Rect,
+    comp_rect: Rect,
+    zoom: f32,
+    ppp: f32,
+    cw: f32,
+    ch: f32,
+    map: ViewerMap,
+    time: Tick,
+    overlays: bool,
+    mods: egui::Modifiers,
+    space_pan: bool,
+    text_owns: bool,
+    gid: egui::Id,
+    track_cur: Option<(LayerId, effectcraft_engine::project::Uid)>,
+    has_3d: bool,
+    ctx: egui::Context,
+    comp_name: String,
+    p: egui::Painter,
+    painter: egui::Painter,
+    selected: Vec,
+    sel_vertices: Vec,
+    ft: Option,
+    comp: &'a Comp,
+    gesture: Option,
+    handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)>,
+    axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])>,
+    vertex_hits: Vec,
+    vertex_marks: Vec,
+    key_hits: Vec,
+    motion_frames: Vec,
+    paths: Vec,
+    track_hits: Vec,
+    pin_hits: Vec,
+    puppet_layers: Vec<(&'a Layer, super::puppet_tool::Overlay)>,
+}
+
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
@@ -83,7 +129,75 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
     let p = ui.painter().clone();
     VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
+    let mut v = Vw {
+        t,
+        cid,
+        nav: Rect::NOTHING,
+        bar: Rect::NOTHING,
+        err_bar: Rect::NOTHING,
+        err_h: 0.0,
+        pasteboard: Color32::BLACK,
+        area: Rect::NOTHING,
+        outer: Rect::NOTHING,
+        comp_rect: Rect::NOTHING,
+        zoom: 1.0,
+        ppp: 1.0,
+        cw: 0.0,
+        ch: 0.0,
+        map: ViewerMap { origin: Pos2::ZERO, zoom: 1.0, comp: [0.0; 2], area: Rect::NOTHING },
+        time: Tick::ZERO,
+        overlays: false,
+        mods: egui::Modifiers::NONE,
+        space_pan: false,
+        text_owns: false,
+        gid: egui::Id::unique("viewer-gesture"),
+        track_cur: None,
+        has_3d: false,
+        ctx: ctx.clone(),
+        comp_name,
+        painter: p.clone(),
+        p,
+        selected: vec![],
+        sel_vertices: vec![],
+        ft: None,
+        comp: &comp,
+        gesture: None,
+        handle_hits: vec![],
+        axis_hits: vec![],
+        vertex_hits: vec![],
+        vertex_marks: vec![],
+        key_hits: vec![],
+        motion_frames: vec![],
+        paths: vec![],
+        track_hits: vec![],
+        pin_hits: vec![],
+        puppet_layers: vec![],
+    };
+    phase_nav(app, ui, rect, &mut v);
+    phase_layout(app, ui, rect, &mut v);
+    phase_frame(app, &mut v);
+    let snap_project = app.session.project.clone();
+    let snap_expr = app.session.expr.clone();
+    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None };
+    phase_guides(app, &ectx, &mut v);
+    phase_layers(app, ui, &ectx, &mut v);
+    phase_scene(app, &ectx, &mut v);
+    phase_rigs(app, ui, &ectx, &mut v);
+    let resp = phase_menu(app, ui, &ectx, &mut v);
+    phase_hover(app, ui, &ectx, &resp, &mut v);
+    phase_press(app, ui, &ectx, &resp, &mut v);
+    phase_dragstart(app, ui, &ectx, &resp, &mut v);
+    phase_drag(app, ui, &ectx, &resp, &mut v);
+    phase_stopped(app, ui, &ectx, &resp, &mut v);
+    phase_clicked(app, &ectx, &resp, &mut v);
+    phase_dblclick(app, &ectx, &resp, &mut v);
+    phase_tail(app, ui, &ectx, &mut v);
+}
 
+fn phase_nav(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let p = v.p.clone();
     // 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.
@@ -116,7 +230,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         x = r.max.x + 4.0;
     }
+    v.nav = nav;
+}
 
+fn phase_layout(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let nav = v.nav;
+    let ctx = v.ctx.clone();
+    let p = v.p.clone();
+    let comp = v.comp;
     // Bottom control bar.
     let bar_h = 36.0;
     let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
@@ -128,7 +251,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     // View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
     // keeps the overlays and the interaction.
     let full = locked_pane(app, ui, full, pasteboard);
-    let area = aux_views(app, ui, &comp, cid, full, pasteboard);
+    let area = aux_views(app, ui, comp, cid, full, pasteboard);
     p.rect_filled(area, 0.0, pasteboard);
     // View ▸ Show Rulers takes a strip on the top and left.
     let outer = area;
@@ -151,8 +274,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let painter = p.with_clip_rect(area);
     // Settings ▸ 3D ▸ Extended Viewer: custom 3D views (and Draft 3D) render the visible
     // pasteboard too, so 3D layers reaching past the comp frame stay visible there.
-    app.ui.viewer.extended = extended_region(app, &comp, cid, map);
+    app.ui.viewer.extended = extended_region(app, comp, cid, map);
+    v.bar = bar;
+    v.err_bar = err_bar;
+    v.err_h = err_h;
+    v.pasteboard = pasteboard;
+    v.area = area;
+    v.outer = outer;
+    v.comp_rect = comp_rect;
+    v.zoom = zoom;
+    v.ppp = ppp;
+    v.cw = cw;
+    v.ch = ch;
+    v.map = map;
+    v.painter = painter;
+}
 
+fn phase_frame(app: &mut EffectcraftApp, v: &mut Vw<'_>) {
+    let cid = v.cid;
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let ppp = v.ppp;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // Frame.
     let scale = app.viewer_scale(zoom, ppp);
     if !app.playback.playing {
@@ -185,11 +330,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let bg = comp.background;
         painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
     }
-    let snap_project = app.session.project.clone();
-    let snap_expr = app.session.expr.clone();
-    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
+    v.time = time;
+}
+
+fn phase_guides(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let cid = v.cid;
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let comp_name = v.comp_name.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
-    vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
+    vt::draw_frame(app, &ctx, &painter, comp_rect, cid, ectx);
     if app.ui.viewer.extended.is_some() {
         // The comp frame outlined over the extended render.
         painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
@@ -275,17 +430,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         app.auto.add("viewer.regionOfInterest", r, "Region of interest");
     }
+    v.overlays = overlays;
+}
+
+fn phase_layers(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let mut handle_hits = std::mem::take(&mut v.handle_hits);
+    let mut axis_hits = std::mem::take(&mut v.axis_hits);
+    let mut vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let mut vertex_marks = std::mem::take(&mut v.vertex_marks);
+    let mut key_hits = std::mem::take(&mut v.key_hits);
+    let mut motion_frames = std::mem::take(&mut v.motion_frames);
+    let mut paths = std::mem::take(&mut v.paths);
+    let comp = v.comp;
     // Overlays + interaction.
     let selected = app.session.state.selected_layers.clone();
     // Bounding-box handles: (layer, handle, screen point, its resize cursor).
-    let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new();
     // 3D gizmo arrows: (layer, grabbable screen segment, position change per comp px in x and y).
-    let mut axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> = Vec::new();
-    let mut vertex_hits: Vec = Vec::new();
-    let mut vertex_marks: Vec = Vec::new();
-    let mut key_hits: Vec = Vec::new();
-    let mut motion_frames: Vec = Vec::new();
-    let mut paths: Vec = Vec::new();
     let sel_vertices = app.session.state.selected_vertices.clone();
     // Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
     // free transform.
@@ -308,7 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for l in comp.layers.iter().filter(|l| {
             matches!(l.source, LayerSource::Null) && !selected.contains(&l.id) && l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo)
         }) {
-            let outline = layer_outline(&ectx, l);
+            let outline = layer_outline(ectx, l);
             if outline.len() > 2 {
                 let col = if label_handles { t.label(l.label) } else { t.accent };
                 painter.add(egui::Shape::closed_line(outline.into_iter().map(|p| map.to_screen(p)).collect(), Stroke::new(1.0, col)));
@@ -322,12 +490,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && (l.switches.video || l.source.is_av())) {
             let col = if label_handles { t.label(l.label) } else { t.accent };
             // Motion path of animated position (keyframes and spatial tangents are draggable).
-            ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames);
+            ov::motion_path(app, &painter, &map, ectx, l, col, &mut key_hits, &mut motion_frames);
             // Masks and shape paths.
             if app.ui.viewer.show_masks {
                 let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
                 let hover = ui.input(|i| i.pointer.hover_pos());
-                ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
+                ov::draw_paths(app, &painter, &map, ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
             }
             // Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a
             // selected collapsed precomp.
@@ -336,7 +504,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 && let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
                 && let Some(nc) = app.session.project.comp(*item)
             {
-                let (outer, _) = l2c(&ectx, l);
+                let (outer, _) = l2c(ectx, l);
                 let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.nested_time(l));
                 for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) {
                     if let Some((_, q, _)) = layer_quad(&nctx, nl) {
@@ -359,9 +527,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             if editing_path {
                 continue;
             }
-            let Some((m, q, _)) = layer_quad(&ectx, l) else {
+            let Some((m, q, _)) = layer_quad(ectx, l) else {
                 // A 3D layer reaching past the camera: the part of its box in front of it.
-                let outline = layer_outline(&ectx, l);
+                let outline = layer_outline(ectx, l);
                 if outline.len() > 2 {
                     painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col)));
                     continue;
@@ -396,10 +564,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // 3D axis gizmo.
                 if l.is_3d() {
                     let w = ectx.world_matrix(l);
-                    let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w;
+                    let pc = cam_state(ectx).projection(cw as f64, ch as f64) * w;
                     let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]);
                     let len = 60.0 / zoom as f64;
-                    let pinv = parent_world_inverse(&ectx, l);
+                    let pinv = parent_world_inverse(ectx, l);
                     for (axis, col, name) in [
                         (effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "x"),
                         (effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "y"),
@@ -428,7 +596,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.vertex_marks = vertex_marks;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.selected = selected;
+    v.sel_vertices = sel_vertices;
+}
 
+fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let time = v.time;
+    let painter = v.painter.clone();
+    let selected = v.selected.clone();
+    let vertex_marks = std::mem::take(&mut v.vertex_marks);
+    let mut track_hits = std::mem::take(&mut v.track_hits);
+    let comp = v.comp;
     // Path points last, over the boxes' handles (#1042).
     ov::draw_vertex_marks(&painter, &vertex_marks, app.session.prefs.general.path_point_size as f32 + 1.0);
 
@@ -437,7 +629,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time));
     if has_3d && app.session.prefs.three_d.extended_viewer {
         for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) {
-            let q = layer_outline(&ectx, l);
+            let q = layer_outline(ectx, l);
             if q.len() > 2 {
                 let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect();
                 let outside = sq.iter().any(|p| !comp_rect.contains(*p));
@@ -448,7 +640,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     if has_3d && app.session.prefs.three_d.show_reference_axes {
-        let pc = cam_state(&ectx).projection(cw as f64, ch as f64);
+        let pc = cam_state(ectx).projection(cw as f64, ch as f64);
         let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0);
         let o = pc.apply(o3);
         let corner = pos2(area.min.x + 34.0, area.max.y - 34.0);
@@ -467,7 +659,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 
     // Track points of the current track (Tracker panel) on its layer, while that layer is selected.
-    let mut track_hits = vec![];
     let track_cur = super::tracker::viewer_track(app);
     if app.layer_controls_visible()
         && let Some((tl, tu)) = track_cur
@@ -475,7 +666,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && let Some(layer) = comp.layer(tl)
         && layer.is_active_at(time)
     {
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
     }
 
@@ -487,7 +678,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && let Some(layer) = comp.layer(wl)
         && layer.is_active_at(time)
     {
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
         let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
         let at = map.to_screen([c.x, c.y]);
@@ -509,7 +700,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         if notices >= 3 {
             break;
         }
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
         let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
         let at = map.to_screen([c.x, c.y]);
@@ -522,14 +713,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text);
         notices += 1;
     }
+    v.vertex_marks = vertex_marks;
+    v.track_hits = track_hits;
+    v.track_cur = track_cur;
+    v.has_3d = has_3d;
+}
 
+fn phase_rigs(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let area = v.area;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let selected = v.selected.clone();
+    let paths = std::mem::take(&mut v.paths);
+    let mut pin_hits = std::mem::take(&mut v.pin_hits);
+    let mut puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
     // Free transform box of a path (double-click a path with the Selection tool).
     let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);
 
     // Cameras and lights (wireframes) and the 3D view name.
     if comp.has_3d() {
         let through_active = VIEW_CAM.with(|c| c.get()).is_none();
-        draw_rigs(app, &painter, &map, &ectx, &selected, cam_state(&ectx), through_active, None);
+        draw_rigs(app, &painter, &map, ectx, &selected, cam_state(ectx), through_active, None);
         let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
         let label = match (view, comp.active_camera(time)) {
             (View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name),
@@ -539,10 +748,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 
     // Puppet pins and meshes of the selected layers (Puppet tools).
-    let mut pin_hits: Vec = vec![];
     // The selected layers with a Puppet effect, top of the stack first (the layers the Puppet
     // tools work on).
-    let mut puppet_layers: Vec<(&Layer, super::puppet_tool::Overlay)> = vec![];
     if app.ui.tool.puppet_kind().is_some() {
         let sel_props: Vec = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
         for lid in &selected {
@@ -550,7 +757,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 && let Some(ov) = super::puppet_tool::overlay(app, &ctx, cid, l, time)
             {
                 let hover = ui.input(|i| i.pointer.hover_pos());
-                pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
+                pin_hits.extend(super::puppet_tool::draw(&painter, &map, ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
                 puppet_layers.push((l, ov));
             }
         }
@@ -564,17 +771,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
-    // The layer a Puppet tool press works on: while selected layers are rigged, only those
-    // ([`super::puppet_tool::target`]); otherwise the layer under the pointer, selected.
-    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
-        if puppet_layers.is_empty() {
-            return pick(app, &ectx, cpt, false);
-        }
-        super::puppet_tool::target(&ectx, &puppet_layers, cpt, |l| layers_at(&ectx, cpt).any(|h| h.id == l.id))
-    };
+    v.paths = paths;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+    v.ft = ft;
+}
 
+fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) -> egui::Response {
+    let cid = v.cid;
+    let area = v.area;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // Project items, files and effects dropped on the viewer.
-    super::viewer_drop::show(app, ui, &painter, &map, &ectx);
+    super::viewer_drop::show(app, ui, &painter, &map, ectx);
 
     // Interaction.
     let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag());
@@ -584,9 +795,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         // Picking an already selected layer preserves the group; an unselected hit
         // becomes the target, using the same visible/unlocked hit test as left-click.
-        pick(app, &ectx, map.to_comp(pos), false);
+        pick(app, ectx, map.to_comp(pos), false);
         let point = map.to_comp(pos);
-        let hits: Vec<(u64, String)> = layers_at(&ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
+        let hits: Vec<(u64, String)> = layers_at(ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
         ui.data_mut(|d| d.insert_temp(menu_hits_id, hits));
     }
     let context_layers: Vec =
@@ -641,6 +852,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             app.ui.status = e;
         }
     }
+    resp
+}
+
+fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let axis_hits = std::mem::take(&mut v.axis_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
+    let track_hits = std::mem::take(&mut v.track_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let comp = v.comp;
     // Path vertices and tangent handles take the press before the bounding-box handles (#1042).
     let vertex_at = |pos: Pos2, tangents: bool| -> Option {
         let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
@@ -675,7 +906,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // The tool draws its own crosshair (and the layer it would matte) while no box
                 // is being dragged.
                 if ui.data(|d| d.get_temp::(egui::Id::unique("viewer-gesture"))).is_none() {
-                    super::object_matte_tool::hover(app, &painter, &map, &ectx, hp);
+                    super::object_matte_tool::hover(app, &painter, &map, ectx, hp);
                 }
                 egui::CursorIcon::None
             }
@@ -689,7 +920,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             _ => {
                 if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
                     egui::CursorIcon::Move
-                } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
+                } else if let Some((_, vertical)) = vt::guide_at(app, comp, &map, hp) {
                     if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
                 } else if vertex_at(hp, true).or_else(|| vertex_at(hp, false)).is_some() {
                     // A path point under a box handle is grabbed first: no resize cursor.
@@ -719,6 +950,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     } else {
         app.pointer_comp = None;
     }
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.track_hits = track_hits;
+    v.pin_hits = pin_hits;
+    v.gid = gid;
+}
+
+fn phase_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
     app.ui.viewer.interacting = resp.dragged();
     let mods = ui.input(|i| i.modifiers);
     let space_pan = super::space_hand(&ctx);
@@ -730,7 +979,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && ui.input(|i| i.pointer.primary_pressed())
         && let Some(pos) = ui.input(|i| i.pointer.press_origin())
     {
-        pen_press(app, ui, &ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
+        pen_press(app, ui, ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
     }
     // A pen click (press + release without a drag) ends its tangent gesture on release.
     if ui.input(|i| i.pointer.primary_released())
@@ -740,7 +989,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         ui.data_mut(|d| d.remove::(egui::Id::unique("viewer-gesture")));
     }
     // On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
-    let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
+    let text_owns = super::viewer_text::hook(app, ui, &painter, &map, ectx, resp, space_pan);
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.mods = mods;
+    v.space_pan = space_pan;
+    v.text_owns = text_owns;
+}
+
+fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let map = v.map;
+    let time = v.time;
+    let mods = v.mods;
+    let space_pan = v.space_pan;
+    let text_owns = v.text_owns;
+    let gid = v.gid;
+    let track_cur = v.track_cur;
+    let ft = std::mem::take(&mut v.ft);
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let axis_hits = std::mem::take(&mut v.axis_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let paths = std::mem::take(&mut v.paths);
+    let track_hits = std::mem::take(&mut v.track_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
+    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
+        if puppet_layers.is_empty() {
+            return pick(app, ectx, cpt, false);
+        }
+        super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
+    };
     if resp.drag_started()
         && !text_owns
         && let Some(pos) = resp.interact_pointer_pos()
@@ -760,8 +1047,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => {
                 ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay)
             }
-            Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => {
-                vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
+            Tool::Selection if vt::guide_at(app, comp, &map, press).is_some() => {
+                vt::guide_at(app, comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
             }
             // Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull
             // new tangents out of it.
@@ -771,9 +1058,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press }
                 })
             }
-            Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
-            Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
-                let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
+            Tool::MaskFeather => ov::begin_feather(app, ectx, &paths, &map, press).map(Gesture::Overlay),
+            Tool::Rotate => pick(app, ectx, cpt, mods.shift).map(|l| {
+                let others = selected_others(app, comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
                 let layer = comp.layer(l).cloned();
                 let center = layer
                     .as_ref()
@@ -790,10 +1077,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     others,
                 }
             }),
-            Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
+            Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| {
                 let layer = comp.layer(l)?;
                 let tr = layer.transform()?;
-                let (l2c, _) = l2c(&ectx, layer);
+                let (l2c, _) = l2c(ectx, layer);
                 let l2p = ectx.local_matrix(layer);
                 let l2p = Mat3([[l2p.0[0][0], l2p.0[0][1], 0.0], [l2p.0[1][0], l2p.0[1][1], 0.0], [0.0, 0.0, 1.0]]);
                 Some(Gesture::Anchor {
@@ -804,7 +1091,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     start: map.to_comp(press),
                     inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
                     l2p,
-                    own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false, app.session.state.snap_features),
+                    own: effectcraft_engine::viewer::layer_targets(ectx, layer, false, app.session.state.snap_features),
                     anchor_only: mods.alt,
                 })
             }),
@@ -830,7 +1117,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     PinPart::Center => {}
                 }
                 let l = comp.layer(h.layer)?;
-                let inv = fx2c(&ectx, l).inverse()?;
+                let inv = fx2c(ectx, l).inverse()?;
                 if mods.command {
                     let lp = inv.apply(gv2(cpt[0], cpt[1]));
                     let began = ui.input(|i| i.time);
@@ -850,7 +1137,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let hit = super::tracker::hit_at(&track_hits, press);
                 track_cur.zip(hit).and_then(|((tl, tu), hit)| {
                     let layer = comp.layer(tl)?;
-                    let (m, _) = l2c(&ectx, layer);
+                    let (m, _) = l2c(ectx, layer);
                     super::tracker::begin_drag(layer, tu, time, &m, hit, map.to_comp(press)).map(|drag| Gesture::Track { layer: tl, tracker: tu, drag })
                 })
             }
@@ -890,11 +1177,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     let layer = comp.layer(lid).cloned();
                     layer.and_then(|layer| {
                         let tr = layer.transform()?;
-                        let (l2c, _) = l2c(&ectx, &layer);
+                        let (l2c, _) = l2c(ectx, &layer);
                         let inv = l2c.inverse()?;
                         let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
                         let lp = inv.apply(gv2(cpt[0], cpt[1]));
-                        let hp = handle_point(effectcraft_engine::render::content_bounds(&ectx, &layer)?, hi);
+                        let hp = handle_point(effectcraft_engine::render::content_bounds(ectx, &layer)?, hi);
                         Some(Gesture::Scale {
                             layer: lid,
                             start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
@@ -904,13 +1191,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                             axes: handle_axes(hi),
                             l2c,
                             inv,
-                            others: selected_others(app, &comp, lid)
+                            others: selected_others(app, comp, lid)
                                 .into_iter()
                                 .filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3]))))
                                 .collect(),
                         })
                     })
-                } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
+                } else if let Some(l) = pick(app, ectx, cpt, mods.shift) {
                     let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
                         .session
                         .state
@@ -920,12 +1207,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                         .filter(|l| !l.switches.locked)
                         .filter_map(|l| {
                             let p0 = ectx.v3(l, l.transform()?, "position", [0.0; 3]);
-                            let (ax, ay) = drag_axes(&ectx, l);
+                            let (ax, ay) = drag_axes(ectx, l);
                             Some((l.id, p0, ax, ay))
                         })
                         .collect();
                     // Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
-                    let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
+                    let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(ectx, layer)).and_then(|f| {
                         let c = map.to_comp(press);
                         f.into_iter().min_by(|a, b| {
                             let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
@@ -954,6 +1241,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ui.data_mut(|d| d.insert_temp(gid, g));
         }
     }
+    v.ft = ft;
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.paths = paths;
+    v.track_hits = track_hits;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+}
+
+fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let t = v.t;
+    let zoom = v.zoom;
+    let map = v.map;
+    let mods = v.mods;
+    let gid = v.gid;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
     let gesture: Option = ui.data(|d| d.get_temp(gid));
     if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
         let cpt = map.to_comp(pos);
@@ -970,7 +1276,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let mut d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
                 if let Some(src) = snap_src {
                     let ids: Vec = layers.iter().map(|x| x.0).collect();
-                    let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
+                    let c = vt::snap(app, &ctx, ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
                     d = [d[0] + c[0], d[1] + c[1]];
                     if vt::snapping_on(app, mods) {
                         vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]]));
@@ -987,7 +1293,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
                 // The handle snaps (to the comp's and other layers' edges, corners and centres),
                 // so a layer scales exactly to them, e.g. to the comp's size.
-                let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
+                let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, ectx, &map, &[layer], &[p], mods));
                 for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
                     let v = [s0[0] * f[0], s0[1] * f[1], s0[2]];
                     let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
@@ -1013,7 +1319,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
                 let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
                 let want = [anchor_c.x + d[0], anchor_c.y + d[1]];
-                let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own);
+                let sc = vt::snap_with(app, &ctx, ectx, &map, &vt::SnapQuery { exclude: &[layer], sources: &[want], mods, extra: &own });
                 let a = inv.apply(gv2(start[0], start[1]));
                 let b = inv.apply(gv2(start[0] + d[0] + sc[0], start[1] + d[1] + sc[1]));
                 let dl = gv2(b.x - a.x, b.y - a.y);
@@ -1048,7 +1354,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             Gesture::Vertices { start, src, applied, inv, layer } => {
                 // The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
                 let want = [cpt[0] - start[0], cpt[1] - start[1]];
-                let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
+                let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
                 let total = [want[0] + c[0], want[1] + c[1]];
                 let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
                 if d.x != 0.0 || d.y != 0.0 {
@@ -1067,7 +1373,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
                 }
                 d => {
-                    let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods);
+                    let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, ectx, &map, &[l], &[p], mods);
                     if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) {
                         ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd)));
                     }
@@ -1148,6 +1454,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.gesture = gesture;
+}
+
+fn phase_stopped(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let map = v.map;
+    let time = v.time;
+    let mods = v.mods;
+    let gid = v.gid;
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let gesture = v.gesture.clone();
+    let comp = v.comp;
     if resp.drag_stopped() {
         if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) {
             match g {
@@ -1181,7 +1501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     }
                 }
                 Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
-                Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, &ectx, &map, start, Some(pos), mods.shift),
+                Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, ectx, &map, start, Some(pos), mods.shift),
                 Gesture::Create { tool, start } => {
                     let end = map.to_comp(pos);
                     create_shape(app, tool, start, end, mods);
@@ -1214,8 +1534,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 Gesture::Marquee { start } => {
                     let a = map.to_comp(start);
                     let b = map.to_comp(pos);
-                    let hits: Vec = selectable_layers(&comp, time)
-                        .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
+                    let hits: Vec = selectable_layers(comp, time)
+                        .filter(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
                         .map(|l| l.id.0)
                         .collect();
                     let _ = app.session.execute("layer.select", json!({"layers": hits, "toggle": mods.shift}));
@@ -1229,6 +1549,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             d.insert_temp(egui::Id::unique("viewer-drag-n"), n + 1);
         });
     }
+    v.vertex_hits = vertex_hits;
+    v.pin_hits = pin_hits;
+}
+
+fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let mods = v.mods;
+    let text_owns = v.text_owns;
+    let ctx = v.ctx.clone();
+    let ft = std::mem::take(&mut v.ft);
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
+    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
+        if puppet_layers.is_empty() {
+            return pick(app, ectx, cpt, false);
+        }
+        super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
+    };
     if resp.clicked()
         && !text_owns
         && let Some(pos) = resp.interact_pointer_pos()
@@ -1285,13 +1637,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // Clicking away from the free-transform box ends it.
                 ov::end_free_transform(&ctx);
             }
-            Tool::ObjectMatte => super::object_matte_tool::apply(app, &ectx, &map, pos, None, mods.shift),
+            Tool::ObjectMatte => super::object_matte_tool::apply(app, ectx, &map, pos, None, mods.shift),
             t if t.puppet_kind().is_some() => {
                 if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
                     // Click selects the pin; Shift-click adds or removes it.
                     let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
                 } else if let Some(l) = puppet_layer(app, cpt).and_then(|l| comp.layer(l))
-                    && let Some(inv) = fx2c(&ectx, l).inverse()
+                    && let Some(inv) = fx2c(ectx, l).inverse()
                 {
                     let lp = inv.apply(gv2(cpt[0], cpt[1]));
                     match app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})) {
@@ -1312,13 +1664,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
             // The Roto Brush paints in the Layer panel only: select as usual and say so.
             rt if rt.roto_kind(false).is_some() => {
-                if pick(app, &ectx, cpt, mods.shift).is_none() {
+                if pick(app, ectx, cpt, mods.shift).is_none() {
                     let _ = app.session.execute("edit.deselectAll", json!({}));
                 }
                 app.ui.status = crate::i18n::tr(ROTO_HINT).to_string();
             }
             _ => {
-                if pick(app, &ectx, cpt, mods.shift).is_none() {
+                if pick(app, ectx, cpt, mods.shift).is_none() {
                     let _ = app.session.execute("edit.deselectAll", json!({}));
                 } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() {
                     // A click away from the paths, even on their layer, deselects the mask
@@ -1328,6 +1680,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.ft = ft;
+    v.handle_hits = handle_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+}
+
+fn phase_dblclick(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let map = v.map;
+    let text_owns = v.text_owns;
+    let ctx = v.ctx.clone();
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let paths = std::mem::take(&mut v.paths);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
     if resp.double_clicked()
         && !text_owns
         && app.ui.tool == Tool::Selection
@@ -1349,7 +1723,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         let cpt = map.to_comp(pos);
         let roto = app.ui.tool.roto_kind(false).is_some();
-        if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
+        if let Some(l) = pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) {
             match &l.source {
                 effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
                 // With a Roto Brush tool, double-clicking a layer opens it in the Layer panel,
@@ -1365,15 +1739,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.handle_hits = handle_hits;
+    v.vertex_hits = vertex_hits;
+    v.paths = paths;
+}
+
+fn phase_tail(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let bar = v.bar;
+    let err_bar = v.err_bar;
+    let err_h = v.err_h;
+    let area = v.area;
+    let outer = v.outer;
+    let zoom = v.zoom;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
     // the effect is selected), and its analysis banner.
-    super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
-    crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Selected shape gradients' start and end points (#612).
-    crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Selected text Range Selectors' Start and End bars (#1011).
-    crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
 
     // Status chips (render time / caching).
     if app.playback.playing && app.playback.waiting {
@@ -1388,14 +1781,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("viewer-video-off-chip"), r));
     }
 
-    vt::draw_snap(&ctx, &painter, &map, &ectx);
+    vt::draw_snap(&ctx, &painter, &map, ectx);
     vt::rulers(app, ui, &map, outer, area);
-    vt::bottom_bar(app, ui, bar, zoom, time, &comp);
+    vt::bottom_bar(app, ui, bar, zoom, time, comp);
     if err_h > 0.0 {
         super::expr_bar::draw(app, ui, err_bar, cid);
     }
 }
-
 pub(crate) use crate::widgets::hex_rgb;
 
 mod frame;
diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs
index 215a067ac..ac467ecd5 100644
--- a/crates/ui-egui/src/panels/viewer/frame.rs
+++ b/crates/ui-egui/src/panels/viewer/frame.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Align2, EffectcraftApp, Pos2, Rect, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, vec2, vt, widgets};
 
 /// Draw the no-composition viewer with New Composition and New Composition From Footage buttons, accepting Project
 /// items dropped on it to create the first composition.
diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs
index 9c42cc5ef..847332bcf 100644
--- a/crates/ui-egui/src/panels/viewer/geometry.rs
+++ b/crates/ui-egui/src/panels/viewer/geometry.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{CameraState, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, VIEW_CAM, gv2, three_d, vec2};
 
 /// The camera the viewer shows (view camera or the comp's active camera).
 pub(super) fn cam_state(ctx: &EvalCtx) -> CameraState {
diff --git a/crates/ui-egui/src/panels/viewer/gesture.rs b/crates/ui-egui/src/panels/viewer/gesture.rs
index 72f6d14b5..db024e720 100644
--- a/crates/ui-egui/src/panels/viewer/gesture.rs
+++ b/crates/ui-egui/src/panels/viewer/gesture.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{EffectcraftApp, LayerId, Mat3, Pos2, Rect, Tick, Tool, ViewerMap, ov, vec2};
 
 /// What a drag in the viewer is doing.
 #[derive(Clone, Debug)]
diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs
index 0cf72bb52..395219a05 100644
--- a/crates/ui-egui/src/panels/viewer/shapes.rs
+++ b/crates/ui-egui/src/panels/viewer/shapes.rs
@@ -1,5 +1,8 @@
 use super::geometry::{l2c, layer_quad, point_in_quad};
-use super::*;
+use super::{
+    Color32, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, LayerSource, Mat3, PaintKind, Pos2, Rect, Stroke, Tick, Tokens, Tool, ViewerMap, gv2, json, ov,
+    pos2,
+};
 
 /// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order.
 /// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After
diff --git a/crates/ui-egui/src/panels/viewer/tools.rs b/crates/ui-egui/src/panels/viewer/tools.rs
index 3703d999d..8a3fbf0f3 100644
--- a/crates/ui-egui/src/panels/viewer/tools.rs
+++ b/crates/ui-egui/src/panels/viewer/tools.rs
@@ -1,6 +1,6 @@
 use super::geometry::{cam_state, l2c, parent_inverse};
 use super::gesture::{Gesture, pen_id};
-use super::*;
+use super::{CameraState, Color32, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, Rect, Stroke, Tool, ViewerMap, gv2, json, ov, three_d, vec2, vt};
 
 /// The position change per comp pixel of drag (x, y) that moves a layer by `dir` when the
 /// pointer covers the axis' on-screen vector `s` (comp px); drag across the axis is ignored.
diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs
index 1b8ef2292..30b3f4d00 100644
--- a/crates/ui-egui/src/panels/viewer/view.rs
+++ b/crates/ui-egui/src/panels/viewer/view.rs
@@ -1,5 +1,5 @@
 use super::tools::draw_rigs;
-use super::*;
+use super::{Align2, Color32, EffectcraftApp, EvalCtx, Rect, Sense, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, three_d, vec2, widgets};
 
 /// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle.
 pub(super) fn aux_views(
diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs
index f2becd9b1..ce387f6ca 100644
--- a/crates/ui-egui/src/panels/viewer_text.rs
+++ b/crates/ui-egui/src/panels/viewer_text.rs
@@ -810,6 +810,91 @@ fn typing_key(ui: &egui::Ui, bump: bool) -> String {
     format!("text-typing-{n}")
 }
 
+/// A press inside the edited text layer: grab a box handle, place the caret (shift extends from
+/// `shift_anchor`), or, outside the text, commit the edit. Returns the drag it starts, if any.
+fn press_on_edited(app: &mut EffectcraftApp, ui: &egui::Ui, map: &ViewerMap, e: &Edited, pos: Pos2, shift_anchor: Option) -> Option {
+    if let Some(h) = e.handles().iter().position(|h| e.to_screen(map, *h).distance(pos) < 7.0) {
+        let b = e.doc.box_size.unwrap_or([0.0; 2]);
+        return Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
+    }
+    if e.contains(map, pos) {
+        let ci = e.hit(map, pos);
+        exec(app, "text.setSelection", json!({"anchor": shift_anchor.unwrap_or(ci), "caret": ci}));
+        typing_key(ui, true);
+        return Some(Drag::Select { layer: e.layer.id });
+    }
+    // Clicking elsewhere commits the edit.
+    exec(app, "text.endEdit", json!({}));
+    None
+}
+
+/// A press with the Type tool that missed the edited text: edit the text layer under it, or
+/// start creating a new one.
+fn press_type_tool(app: &mut EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, pos: Pos2, tool: Tool) -> Drag {
+    if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
+        let ci = e.hit(map, pos);
+        exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
+        Drag::Select { layer: e.layer.id }
+    } else {
+        Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical }
+    }
+}
+
+/// The paragraph box after dragging `handle` by (`dx`, `dy`) in layer space.
+fn resized_box(start_box: [f64; 4], handle: usize, dx: f64, dy: f64) -> [f64; 4] {
+    let [mut x0, mut y0, w, h] = start_box;
+    let (mut x1, mut y1) = (x0 + w, y0 + h);
+    if matches!(handle, 0 | 6 | 7) {
+        x0 += dx;
+    }
+    if matches!(handle, 2..=4) {
+        x1 += dx;
+    }
+    if matches!(handle, 0..=2) {
+        y0 += dy;
+    }
+    if matches!(handle, 4..=6) {
+        y1 += dy;
+    }
+    [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)]
+}
+
+/// Keyboard routing for the text editor: while a session is live, keep the focus on it and feed
+/// it this frame's keys; otherwise release the IME and the focus.
+fn keyboard_focus(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
+        // Register after pointer handling so new edits take focus in this frame. An inactive
+        // editor must not take Tab focus and immediately surrender it before viewer controls.
+        ui.interact(Rect::from_min_size(rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
+        ui.memory_mut(|m| {
+            m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
+        });
+        // A session started elsewhere (menu, agent, Properties) takes the keyboard once…
+        let seen = ime_seen_id(ui.ctx().viewport_id());
+        let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
+        // …and gets it back when a click in the viewer or a formatting panel left nothing holding
+        // it; not merely because nothing has the focus (#831).
+        let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
+        if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain {
+            ui.data_mut(|d| d.insert_temp(seen, key));
+            ui.memory_mut(|m| m.request_focus(focus_id()));
+        }
+        if ui.memory(|m| m.has_focus(focus_id())) {
+            keyboard(app, ui, rect);
+        }
+    } else {
+        // Resolve the viewport before taking the data lock: `viewport_id()`
+        // acquires the same context lock that `data_mut` holds.
+        let viewport = ui.ctx().viewport_id();
+        ui.data_mut(|d| d.insert_temp::>(ime_seen_id(viewport), None));
+        invalidate_ime_composition(ui, app);
+        clear_preedit(ui);
+        if ui.memory(|m| m.has_focus(focus_id())) {
+            ui.memory_mut(|m| m.surrender_focus(focus_id()));
+        }
+    }
+}
+
 /// Viewer hook: draws the edit overlay and handles text pointer and keyboard input. Returns
 /// true when it owns this frame's pointer interaction (the viewer then skips its own gestures).
 pub fn hook(
@@ -858,7 +943,6 @@ pub fn hook(
         }
     });
     let pointer = ui.input(|i| i.pointer.interact_pos());
-    let handle_hit = |e: &Edited, s: Pos2| e.handles().iter().position(|h| e.to_screen(map, *h).distance(s) < 7.0);
 
     // ---- press
     if pressed
@@ -870,30 +954,14 @@ pub fn hook(
     {
         let mut handled = false;
         if let Some(e) = &cur {
-            if let Some(h) = handle_hit(e, pos) {
-                let b = e.doc.box_size.unwrap_or([0.0; 2]);
-                drag = Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
+            let shift_anchor = if mods.shift { Some(editing.as_ref().map_or_else(|| e.hit(map, pos), |sel| sel.anchor)) } else { None };
+            if let Some(d) = press_on_edited(app, ui, map, e, pos, shift_anchor) {
+                drag = Some(d);
                 handled = true;
-            } else if e.contains(map, pos) {
-                let ci = e.hit(map, pos);
-                let anchor = if mods.shift { editing.as_ref().map_or(ci, |sel| sel.anchor) } else { ci };
-                exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
-                typing_key(ui, true);
-                drag = Some(Drag::Select { layer: e.layer.id });
-                handled = true;
-            } else {
-                // Clicking elsewhere commits the edit.
-                exec(app, "text.endEdit", json!({}));
             }
         }
         if !handled && type_tool {
-            if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
-                let ci = e.hit(map, pos);
-                exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
-                drag = Some(Drag::Select { layer: e.layer.id });
-            } else {
-                drag = Some(Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical });
-            }
+            drag = Some(press_type_tool(app, ectx, map, pos, tool));
             handled = true;
         }
         if handled {
@@ -948,22 +1016,7 @@ pub fn hook(
             {
                 let (px, py) = e.to_layer(map, p);
                 let (qx, qy) = e.to_layer(map, q);
-                let (dx, dy) = (px - qx, py - qy);
-                let [mut x0, mut y0, w, h] = start_box;
-                let (mut x1, mut y1) = (x0 + w, y0 + h);
-                if matches!(handle, 0 | 6 | 7) {
-                    x0 += dx;
-                }
-                if matches!(handle, 2..=4) {
-                    x1 += dx;
-                }
-                if matches!(handle, 0..=2) {
-                    y0 += dy;
-                }
-                if matches!(handle, 4..=6) {
-                    y1 += dy;
-                }
-                let bx = [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)];
+                let bx = resized_box(start_box, handle, px - qx, py - qy);
                 exec(app, "layer.setText", json!({"layer": layer.0, "box": bx, "merge": format!("text-box-{}", layer.0)}));
             }
         }
@@ -1009,37 +1062,7 @@ pub fn hook(
     // composition's identity as a stale guard before this pass's IME events are read.
     sync_ime_target(ui, app);
     sync_ime_selection(ui, app);
-    if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
-        // Register after pointer handling so new edits take focus in this frame. An inactive
-        // editor must not take Tab focus and immediately surrender it before viewer controls.
-        ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
-        ui.memory_mut(|m| {
-            m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
-        });
-        // A session started elsewhere (menu, agent, Properties) takes the keyboard once…
-        let seen = ime_seen_id(ui.ctx().viewport_id());
-        let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
-        // …and gets it back when a click in the viewer or a formatting panel left nothing holding
-        // it; not merely because nothing has the focus (#831).
-        let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
-        if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain {
-            ui.data_mut(|d| d.insert_temp(seen, key));
-            ui.memory_mut(|m| m.request_focus(focus_id()));
-        }
-        if ui.memory(|m| m.has_focus(focus_id())) {
-            keyboard(app, ui, resp.rect);
-        }
-    } else {
-        // Resolve the viewport before taking the data lock: `viewport_id()`
-        // acquires the same context lock that `data_mut` holds.
-        let viewport = ui.ctx().viewport_id();
-        ui.data_mut(|d| d.insert_temp::>(ime_seen_id(viewport), None));
-        invalidate_ime_composition(ui, app);
-        clear_preedit(ui);
-        if ui.memory(|m| m.has_focus(focus_id())) {
-            ui.memory_mut(|m| m.surrender_focus(focus_id()));
-        }
-    }
+    keyboard_focus(app, ui, resp.rect);
 
     // ---- overlay
     if let Some(s) = app.session.state.text_edit.clone()
@@ -1219,111 +1242,7 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
                 }
                 typing_key(ui, true);
             }
-            egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
-                use egui::Key;
-                let cmd = m.command;
-                let mv = |app: &mut EffectcraftApp, to: &str| {
-                    exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
-                };
-                match key {
-                    Key::ArrowLeft | Key::ArrowRight if m.alt => {
-                        // Kerning at the caret, tracking over a selection.
-                        let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
-                        let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
-                        if sel.is_empty() {
-                            if sel.caret < doc.char_len() {
-                                let cur = match doc.style_at(sel.caret).kerning {
-                                    Kerning::Manual(v) => v,
-                                    _ => 0.0,
-                                };
-                                exec(
-                                    app,
-                                    "layer.setText",
-                                    json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
-                                );
-                            }
-                        } else {
-                            let r = sel.range();
-                            let cur = doc.style_at(r.start).tracking;
-                            exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
-                        }
-                    }
-                    Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
-                    Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
-                    Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
-                    Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
-                    Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
-                    Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
-                    Key::Backspace => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Delete => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Enter if cmd => {
-                        exec(app, "text.endEdit", json!({}));
-                    }
-                    Key::Enter => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Tab => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Escape => {
-                        exec(app, "text.endEdit", json!({}));
-                    }
-                    // The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
-                    // without Shift (Undo, Redo). Other modifiers on those keys aren't editing
-                    // chords, so they fall through to the binding match below: Shift+A is
-                    // Deselect All's default, and Alt variants are free for a rebound one.
-                    Key::A if cmd && !m.shift && !m.alt => {
-                        exec(app, "text.setSelection", json!({"select": "all"}));
-                    }
-                    Key::Z if cmd && !m.alt => {
-                        let before = ime_stamp(app, &ctx);
-                        if crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Comma | Key::Period if cmd && m.shift => {
-                        let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
-                        let r = sel.range();
-                        let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
-                        let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
-                        exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
-                    }
-                    // The app-wide shortcut dispatcher is skipped while this editor holds the
-                    // keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
-                    // must be matched here and run through the command dispatcher like undo
-                    // above. Matching the command's current binding, not a fixed key, keeps
-                    // rebound shortcuts working and gives every route one final state; the
-                    // forwarding test also refuses chords a focused editor must never surrender
-                    // (see `forwardable_during_text_edit`).
-                    _ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
-                        let _ = crate::menus::invoke(app, &ctx, "edit.deselectAll", json!({}));
-                    }
-                    _ => {}
-                }
-                if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
-                    typing_key(ui, true);
-                }
-            }
+            egui::Event::Key { key, pressed: true, modifiers: m, .. } => key_event(app, ui, &ctx, &sel, key, m),
             _ => {}
         }
         let route_ended = app.session.state.text_edit.is_none() || app.dialog.is_some();
@@ -1344,6 +1263,121 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
     }
 }
 
+/// An application key press in the edited text: caret moves, deletes, insertions, formatting and
+/// the editor's own chords.
+fn key_event(
+    app: &mut EffectcraftApp,
+    ui: &egui::Ui,
+    ctx: &egui::Context,
+    sel: &effectcraft_engine::commands::text_edit::TextEdit,
+    key: egui::Key,
+    m: egui::Modifiers,
+) {
+    use egui::Key;
+    let cmd = m.command;
+    let mv = |app: &mut EffectcraftApp, to: &str| {
+        exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
+    };
+    match key {
+        Key::ArrowLeft | Key::ArrowRight if m.alt => {
+            // Kerning at the caret, tracking over a selection.
+            let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
+            let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
+            if sel.is_empty() {
+                if sel.caret < doc.char_len() {
+                    let cur = match doc.style_at(sel.caret).kerning {
+                        Kerning::Manual(v) => v,
+                        _ => 0.0,
+                    };
+                    exec(
+                        app,
+                        "layer.setText",
+                        json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
+                    );
+                }
+            } else {
+                let r = sel.range();
+                let cur = doc.style_at(r.start).tracking;
+                exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
+            }
+        }
+        Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
+        Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
+        Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
+        Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
+        Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
+        Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
+        Key::Backspace => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Delete => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Enter if cmd => {
+            exec(app, "text.endEdit", json!({}));
+        }
+        Key::Enter => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Tab => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Escape => {
+            exec(app, "text.endEdit", json!({}));
+        }
+        // The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
+        // without Shift (Undo, Redo). Other modifiers on those keys aren't editing
+        // chords, so they fall through to the binding match below: Shift+A is
+        // Deselect All's default, and Alt variants are free for a rebound one.
+        Key::A if cmd && !m.shift && !m.alt => {
+            exec(app, "text.setSelection", json!({"select": "all"}));
+        }
+        Key::Z if cmd && !m.alt => {
+            let before = ime_stamp(app, ctx);
+            if crate::menus::invoke(app, ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Comma | Key::Period if cmd && m.shift => {
+            let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
+            let r = sel.range();
+            let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
+            let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
+            exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
+        }
+        // The app-wide shortcut dispatcher is skipped while this editor holds the
+        // keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
+        // must be matched here and run through the command dispatcher like undo
+        // above. Matching the command's current binding, not a fixed key, keeps
+        // rebound shortcuts working and gives every route one final state; the
+        // forwarding test also refuses chords a focused editor must never surrender
+        // (see `forwardable_during_text_edit`).
+        _ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
+            let _ = crate::menus::invoke(app, ctx, "edit.deselectAll", json!({}));
+        }
+        _ => {}
+    }
+    if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
+        typing_key(ui, true);
+    }
+}
+
 /// Delete the text an IME marks for replacement around the editor's selection.
 ///
 /// `before`/`after` count UTF-8 bytes (`DeleteSurroundingBytes`, what winit reports) or
diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs
index 238a48c37..f86a7bfee 100644
--- a/crates/ui-egui/src/panels/viewer_tools.rs
+++ b/crates/ui-egui/src/panels/viewer_tools.rs
@@ -48,20 +48,21 @@ pub(crate) fn snap(
     sources: &[[f64; 2]],
     mods: egui::Modifiers,
 ) -> [f64; 2] {
-    snap_with(app, ctx, ectx, map, exclude, sources, mods, &[])
+    snap_with(app, ctx, ectx, map, &SnapQuery { exclude, sources, mods, extra: &[] })
 }
 
-/// [`snap`] with `extra` layer targets (the Pan Behind layer's own box).
-pub(crate) fn snap_with(
-    app: &EffectcraftApp,
-    ctx: &egui::Context,
-    ectx: &EvalCtx,
-    map: &ViewerMap,
-    exclude: &[LayerId],
-    sources: &[[f64; 2]],
-    mods: egui::Modifiers,
-    extra: &[vw::SnapTarget],
-) -> [f64; 2] {
+/// What [`snap_with`] snaps: the dragged features and the layers to ignore, plus `extra` layer
+/// targets (the Pan Behind layer's own box).
+pub(crate) struct SnapQuery<'a> {
+    pub exclude: &'a [LayerId],
+    pub sources: &'a [[f64; 2]],
+    pub mods: egui::Modifiers,
+    pub extra: &'a [vw::SnapTarget],
+}
+
+/// [`snap`] with extra layer targets.
+pub(crate) fn snap_with(app: &EffectcraftApp, ctx: &egui::Context, ectx: &EvalCtx, map: &ViewerMap, q: &SnapQuery) -> [f64; 2] {
+    let SnapQuery { exclude, sources, mods, extra } = *q;
     let layers = snapping_on(app, mods);
     let v = &app.ui.viewer;
     let guides = v.snap_guides && v.guides;
@@ -620,6 +621,26 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
     });
 }
 
+/// The exposure drag field (stops) at `er`; a change runs `view.exposure`.
+fn exposure_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, er: Rect, stops: f64) {
+    let mut ev = stops;
+    let resp = ui.put(
+        er,
+        crate::widgets::drag_value(
+            egui::DragValue::new(&mut ev)
+                .speed(0.05)
+                .range(-40.0..=40.0)
+                .fixed_decimals(1)
+                .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
+        ),
+    );
+    let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
+    app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
+    if resp.changed() {
+        let _ = app.session.execute("view.exposure", json!({"stops": ev}));
+    }
+}
+
 /// Lay out the viewer's control row from `bar.min.x`: magnification, resolution, transparency, mask, ROI, grid/guide,
 /// channel/colour management, exposure, snapshot, fast previews, 3D renderer/view and Extended Viewer, timecode and render-time
 /// indicators, running commands or toggling viewer UI state. Returns the width the row
@@ -743,22 +764,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
         let _ = app.session.execute("view.resetExposure", json!({}));
     }
     let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
-    let mut ev = vo.exposure as f64;
-    let resp = ui.put(
-        er,
-        crate::widgets::drag_value(
-            egui::DragValue::new(&mut ev)
-                .speed(0.05)
-                .range(-40.0..=40.0)
-                .fixed_decimals(1)
-                .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
-        ),
-    );
-    let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
-    app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
-    if resp.changed() {
-        let _ = app.session.execute("view.exposure", json!({"stops": ev}));
-    }
+    exposure_field(app, ui, er, vo.exposure as f64);
     x = er.max.x + 4.0;
     // Snapshots.
     if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
diff --git a/crates/ui-egui/src/pointer_wrap/tests.rs b/crates/ui-egui/src/pointer_wrap/tests.rs
index 3843db110..314f7ad3d 100644
--- a/crates/ui-egui/src/pointer_wrap/tests.rs
+++ b/crates/ui-egui/src/pointer_wrap/tests.rs
@@ -67,10 +67,7 @@ impl Scrub {
                     pos2(100.0, 100.0),
                     Id::unique("scrub"),
                     self.value,
-                    1.0,
-                    (-1e9, 1e9),
-                    1,
-                    "",
+                    &crate::widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" },
                     &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
                 );
                 if let Some(v) = value {
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index c870a96db..58383fe43 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -51,20 +51,21 @@ fn tab_stop(ui: &Ui, id: egui::Id) -> bool {
     })
 }
 
+/// How a hot-text field scrubs and prints: `speed` value units per pointer pixel, clamped to
+/// `range`, shown with `decimals` places followed by `suffix`.
+#[derive(Clone, Copy)]
+pub struct HotSpec<'a> {
+    pub speed: f64,
+    pub range: (f64, f64),
+    pub decimals: usize,
+    pub suffix: &'a str,
+}
+
 /// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab
 /// while typing commits and types in the panel's next / previous one, as in After Effects.
 /// Returns (response, Some(new value) when changed, drag finished).
-pub fn hot_number_at(
-    ui: &mut Ui,
-    rect_min: egui::Pos2,
-    id: egui::Id,
-    value: f64,
-    speed: f64,
-    range: (f64, f64),
-    decimals: usize,
-    suffix: &str,
-    t: &Tokens,
-) -> (Rect, Option, bool) {
+pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64, spec: &HotSpec, t: &Tokens) -> (Rect, Option, bool) {
+    let HotSpec { speed, range, decimals, suffix } = *spec;
     let editing_id = id.with("editing");
     let mut editing: Option = ui.data(|d| d.get_temp(editing_id));
     if tab_stop(ui, id) && editing.is_none() {
@@ -163,7 +164,7 @@ pub fn slider_scrub_speed(ui: &ParamUi) -> f64 {
 /// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
 /// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
 pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, speed: f64, suffix: &str, t: &Tokens) -> (Rect, Option, bool) {
-    let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, speed, (-1e6, 1e6), 0, suffix, t);
+    let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, &HotSpec { speed, range: (-1e6, 1e6), decimals: 0, suffix }, t);
     let acc_id = id.with("acc");
     let acc = ui.data(|d| d.get_temp::(acc_id)).unwrap_or(0.0) + nv.map_or(0.0, |n| n - value as f64);
     // (Ten 0.1 steps add up to just under 1.)