Files
effectcraft/crates/ui-egui/src/panels/viewer_tools.rs
T
bflatasticandClaude Opus 5.5 0b2ef8e584 Timeline: Ctrl/Cmd-click on the time display toggles Timecode / Frames (#370)
The Time Display Style could only be changed in Project Settings. As in
After Effects, Ctrl-click (Cmd-click on macOS) on the current-time
display of the Timeline or the Composition panel now toggles it between
Timecode and Frames (Feet + Frames goes to Timecode). It runs
`file.projectSettings {timeDisplay}`, so it is one undo step.

Under the Timeline's display the other style shows now (frames under
timecode, timecode under frames), as in After Effects; it always showed
frames.

Tests: ctrl_click_on_the_time_display_toggles_timecode_and_frames
(ui_inline_edit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:52:56 +07:00

761 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Composition viewer tools: snapping (targets and the highlighted feedback), rulers with guides
//! dragged out of them, the displayed frame (Show Channel, exposure, snapshots, region of
//! interest, Wireframe Fast Previews) and the bottom control bar in After Effects' order.
//!
//! Edits go through engine commands (`view.*`, `comp.*`); the viewer calls in from small hooks.
use effectcraft_engine::commands::viewer_cmds::FastPreviews;
use effectcraft_engine::project::{Comp, ItemId, LayerId};
use effectcraft_engine::render::EvalCtx;
use effectcraft_engine::time::Tick;
use effectcraft_engine::viewer::{self as vw, Channel, Snap, SnapKind, SnapSource};
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
use serde_json::json;
use super::viewer::ViewerMap;
use crate::icons::Icon;
use crate::state::Resolution;
use crate::theme::Tokens;
use crate::{EffectcraftApp, widgets};
// ---------------------------------------------------------------- snapping
/// Snap distance in screen points.
pub(crate) const SNAP_PX: f64 = 8.0;
const SNAP_COLOR: Color32 = Color32::from_rgb(0xff, 0xd2, 0x3c);
fn snap_id() -> egui::Id {
egui::Id::new("viewer-snap")
}
/// Layer snapping is on: the Snapping checkbox (View ▸ Snapping), inverted while Cmd/Ctrl is held.
pub(crate) fn snapping_on(app: &EffectcraftApp, mods: egui::Modifiers) -> bool {
app.session.state.snapping != mods.command
}
/// Snap dragged feature points (`sources`, comp pixels) to the comp's targets: other layers'
/// edges, corners, centres, anchor points and mask/shape vertices and the comp edges and centre
/// (when snapping is on, as the Snapping options allow), guides (View ▸ Snap to Guides) and the
/// grid (View ▸ Snap to Grid). Returns the comp-space correction (zero when nothing is near) and
/// records the feedback for this frame.
pub(crate) fn snap(
app: &EffectcraftApp,
ctx: &egui::Context,
ectx: &EvalCtx,
map: &ViewerMap,
exclude: &[LayerId],
sources: &[[f64; 2]],
mods: egui::Modifiers,
) -> [f64; 2] {
snap_with(app, ctx, ectx, map, exclude, sources, mods, &[])
}
/// [`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] {
let layers = snapping_on(app, mods);
let v = &app.ui.viewer;
let guides = v.snap_guides && v.guides;
let grid = v.snap_grid;
if sources.is_empty() || !(layers || guides || grid) {
return [0.0; 2];
}
let opts = vw::SnapOptions { layers, features: app.session.state.snap_features, guides, grid, grid_spacing: app.session.prefs.grids.grid_spacing };
let mut targets = vw::targets(ectx, exclude, opts);
if layers {
targets.extend_from_slice(extra);
}
match vw::snap(sources, &targets, SNAP_PX / map.zoom.max(1e-3) as f64) {
Some(s) => {
let d = s.delta;
ctx.data_mut(|dd| dd.insert_temp(snap_id(), s));
d
}
None => [0.0; 2],
}
}
/// The snap handle: the dragged layer's feature that snaps (the one nearest where it was
/// grabbed), boxed while snapping is on.
pub(crate) fn draw_handle(painter: &egui::Painter, at: Pos2) {
painter.rect_stroke(Rect::from_center_size(at, vec2(6.0, 6.0)), 0.0, Stroke::new(1.0, SNAP_COLOR), StrokeKind::Middle);
}
/// Draw (and clear) this frame's snap feedback: the target layer's box highlighted, the guide or
/// grid line, and a cross where the feature landed.
pub(crate) fn draw_snap(ctx: &egui::Context, painter: &egui::Painter, map: &ViewerMap, ectx: &EvalCtx) {
let Some(s) = ctx.data(|d| d.get_temp::<Snap>(snap_id())) else { return };
ctx.data_mut(|d| d.remove::<Snap>(snap_id()));
let stroke = Stroke::new(1.5, SNAP_COLOR);
let area = map.area;
for h in &s.hits {
if let SnapSource::Layer(l) | SnapSource::Vertex(l) = h.source
&& let Some(layer) = ectx.comp.layer(l)
&& let Some(b) = effectcraft_engine::render::content_bounds(ectx, layer)
{
let (m, _) = ectx.layer_to_comp(layer);
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
.iter()
.map(|p| {
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
.collect();
painter.add(egui::Shape::closed_line(q, stroke));
}
match h.kind {
SnapKind::Point => {
let c = map.to_screen(h.pos);
painter.rect_stroke(Rect::from_center_size(c, vec2(9.0, 9.0)), 0.0, stroke, StrokeKind::Middle);
}
// A line across the viewer, or along its span (to where the feature landed).
SnapKind::VLine => {
let x = map.to_screen(h.pos).x;
let (a, b) = h.span.map_or((area.min.y, area.max.y), |[a, b]| (map.to_screen([0.0, a.min(s.at[1])]).y, map.to_screen([0.0, b.max(s.at[1])]).y));
painter.line_segment([pos2(x, a), pos2(x, b)], Stroke::new(1.0, SNAP_COLOR));
}
SnapKind::HLine => {
let y = map.to_screen(h.pos).y;
let (a, b) = h.span.map_or((area.min.x, area.max.x), |[a, b]| (map.to_screen([a.min(s.at[0]), 0.0]).x, map.to_screen([b.max(s.at[0]), 0.0]).x));
painter.line_segment([pos2(a, y), pos2(b, y)], Stroke::new(1.0, SNAP_COLOR));
}
}
}
let c = map.to_screen(s.at);
painter.line_segment([c - vec2(8.0, 0.0), c + vec2(8.0, 0.0)], stroke);
painter.line_segment([c - vec2(0.0, 8.0), c + vec2(0.0, 8.0)], stroke);
}
// ---------------------------------------------------------------- rulers and guides
/// Ruler thickness in points.
pub(crate) const RULER: f32 = 16.0;
/// The viewer area left for the image once the rulers (View ▸ Show Rulers) take their strips.
pub(crate) fn inset(app: &EffectcraftApp, area: Rect) -> Rect {
if app.ui.viewer.rulers { Rect::from_min_max(area.min + vec2(RULER, RULER), area.max) } else { area }
}
fn nice(raw: f64) -> f64 {
let mag = 10f64.powf(raw.max(1e-9).log10().floor());
let n = raw / mag;
mag * if n <= 1.0 {
1.0
} else if n <= 2.0 {
2.0
} else if n <= 5.0 {
5.0
} else {
10.0
}
}
/// Draw the rulers (ticks relative to the zero point, live pointer markers) and handle their
/// gestures: drag from a ruler to add a guide, drag the corner to move the zero point
/// (double-click resets it).
pub(crate) fn rulers(app: &mut EffectcraftApp, ui: &mut egui::Ui, map: &ViewerMap, outer: Rect, inner: Rect) {
if !app.ui.viewer.rulers {
return;
}
let t = app.tokens;
let p = ui.painter().clone();
let top = Rect::from_min_max(pos2(inner.min.x, outer.min.y), pos2(inner.max.x, inner.min.y));
let left = Rect::from_min_max(pos2(outer.min.x, inner.min.y), pos2(inner.min.x, inner.max.y));
let corner = Rect::from_min_max(outer.min, inner.min);
let bg = t.panel_bg;
for r in [top, left, corner] {
p.rect_filled(r, 0.0, bg);
}
p.line_segment([top.left_bottom(), top.right_bottom()], Stroke::new(1.0, t.separator));
p.line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.separator));
let o = app.ui.viewer.ruler_origin;
let zoom = map.zoom as f64;
let step = nice(64.0 / zoom.max(1e-6));
let minor = step / if (step / nice(step / 2.0) - 2.0).abs() < 1e-9 { 2.0 } else { 5.0 };
let tick = Stroke::new(1.0, t.text_faint);
let font = Tokens::ui(9.0);
let label = |v: f64| if step >= 1.0 { format!("{}", v.round() as i64) } else { format!("{v:.1}") };
// Horizontal ruler (x).
let pt = p.with_clip_rect(top);
let (c0, c1) = (map.to_comp(top.left_top())[0], map.to_comp(top.right_top())[0]);
let mut k = ((c0 - o[0]) / minor).floor() as i64;
while (o[0] + k as f64 * minor) <= c1 {
let cx = o[0] + k as f64 * minor;
let x = map.to_screen([cx, 0.0]).x;
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
let is_major = major.abs() < minor * 0.01;
pt.line_segment([pos2(x, top.max.y - if is_major { RULER - 2.0 } else { 4.0 }), pos2(x, top.max.y)], tick);
if is_major {
pt.text(pos2(x + 2.0, top.min.y + 1.0), Align2::LEFT_TOP, label(cx - o[0]), font.clone(), t.text_dim);
}
k += 1;
}
// Vertical ruler (y).
let pl = p.with_clip_rect(left);
let (r0, r1) = (map.to_comp(left.left_top())[1], map.to_comp(left.left_bottom())[1]);
let mut k = ((r0 - o[1]) / minor).floor() as i64;
while (o[1] + k as f64 * minor) <= r1 {
let cy = o[1] + k as f64 * minor;
let y = map.to_screen([0.0, cy]).y;
let major = ((k as f64 * minor) / step).round() * step - k as f64 * minor;
let is_major = major.abs() < minor * 0.01;
pl.line_segment([pos2(left.max.x - if is_major { RULER - 2.0 } else { 4.0 }, y), pos2(left.max.x, y)], tick);
if is_major {
let g = pl.layout_no_wrap(label(cy - o[1]), font.clone(), t.text_dim);
pl.add(egui::epaint::TextShape::new(pos2(left.min.x + 1.0, y + g.size().x + 2.0), g, t.text_dim).with_angle(-std::f32::consts::FRAC_PI_2));
}
k += 1;
}
// Live pointer markers.
if let Some(hp) = ui.ctx().pointer_hover_pos()
&& inner.contains(hp)
{
let m = Stroke::new(1.0, t.accent);
pt.line_segment([pos2(hp.x, top.min.y), pos2(hp.x, top.max.y)], m);
pl.line_segment([pos2(left.min.x, hp.y), pos2(left.max.x, hp.y)], m);
}
// Zero point: drag the corner, double-click to reset.
p.line_segment([corner.center() - vec2(4.0, 0.0), corner.center() + vec2(4.0, 0.0)], tick);
p.line_segment([corner.center() - vec2(0.0, 4.0), corner.center() + vec2(0.0, 4.0)], tick);
let cr = ui
.interact(corner, egui::Id::new("vw-ruler-origin"), Sense::click_and_drag())
.on_hover_text(crate::i18n::tr("Ruler zero point (drag to move, double-click to reset)"));
app.auto.add("viewer.ruler.origin", corner, "Ruler zero point");
let fg = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("vw-ruler-fg")));
if cr.dragged()
&& let Some(hp) = cr.interact_pointer_pos()
{
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], Stroke::new(1.0, t.accent));
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], Stroke::new(1.0, t.accent));
}
if cr.drag_stopped()
&& let Some(hp) = cr.interact_pointer_pos()
{
let c = map.to_comp(hp);
app.ui.viewer.ruler_origin = [c[0].round(), c[1].round()];
}
if cr.double_clicked() {
app.ui.viewer.ruler_origin = [0.0, 0.0];
}
// Guides dragged out of the rulers: the top ruler makes horizontal guides, the left one
// vertical guides.
for (r, vertical, id) in [(top, false, "top"), (left, true, "left")] {
let resp = ui.interact(r, egui::Id::new(("vw-ruler", id)), Sense::drag()).on_hover_text(crate::i18n::tr("Drag to create a guide"));
app.auto.add(&format!("viewer.ruler.{id}"), r, if vertical { "Vertical ruler" } else { "Horizontal ruler" });
if resp.hovered() {
ui.ctx().set_cursor_icon(if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical });
}
if resp.dragged()
&& let Some(hp) = resp.interact_pointer_pos()
{
let s = Stroke::new(1.0, guide_color(app));
if vertical {
fg.line_segment([pos2(hp.x, inner.min.y), pos2(hp.x, inner.max.y)], s);
} else {
fg.line_segment([pos2(inner.min.x, hp.y), pos2(inner.max.x, hp.y)], s);
}
}
if resp.drag_stopped()
&& let Some(hp) = resp.interact_pointer_pos()
&& inner.contains(hp)
{
let c = map.to_comp(hp);
let pos = if vertical { c[0] } else { c[1] }.round();
let r = app.session.execute("view.addGuide", json!({"orientation": if vertical { "vertical" } else { "horizontal" }, "position": pos}));
if let Err(e) = r {
app.ui.status = e.to_string();
}
app.ui.viewer.guides = true;
}
}
}
pub(crate) fn guide_color(app: &EffectcraftApp) -> Color32 {
let [r, g, b] = super::viewer::hex_rgb(&app.session.prefs.grids.guide_color).unwrap_or([0x3c, 0xc8, 0xf0]);
Color32::from_rgb(r, g, b)
}
/// The guide under a screen point (visible, unlocked guides only): (index, vertical).
pub(crate) fn guide_at(app: &EffectcraftApp, comp: &Comp, map: &ViewerMap, pos: Pos2) -> Option<(usize, bool)> {
if !app.ui.viewer.guides || app.ui.viewer.lock_guides {
return None;
}
comp.guides.iter().enumerate().find_map(|(i, g)| {
let s = map.to_screen([g.position, g.position]);
let d = if g.vertical { (s.x - pos.x).abs() } else { (s.y - pos.y).abs() };
(d < 4.0).then_some((i, g.vertical))
})
}
/// Proportional grid (Grid and guide options ▸ Proportional Grid): Settings ▸ Grids & Guides
/// horizontal × vertical cells over the comp.
pub(crate) fn proportional_grid(app: &EffectcraftApp, painter: &egui::Painter, comp_rect: Rect) {
if !app.ui.viewer.proportional_grid {
return;
}
let g = &app.session.prefs.grids;
let [r, gg, b] = super::viewer::hex_rgb(&g.grid_color).unwrap_or([120, 160, 255]);
let s = Stroke::new(1.0, Color32::from_rgba_unmultiplied(r, gg, b, 110));
let (nx, ny) = (g.proportional_horizontal.max(1), g.proportional_vertical.max(1));
for i in 1..nx {
let x = comp_rect.min.x + comp_rect.width() * i as f32 / nx as f32;
super::viewer::styled_line(painter, [pos2(x, comp_rect.min.y), pos2(x, comp_rect.max.y)], s, &g.grid_style);
}
for i in 1..ny {
let y = comp_rect.min.y + comp_rect.height() * i as f32 / ny as f32;
super::viewer::styled_line(painter, [pos2(comp_rect.min.x, y), pos2(comp_rect.max.x, y)], s, &g.grid_style);
}
}
// ---------------------------------------------------------------- the displayed frame
/// Remember the region of interest the displayed texture was rendered with.
pub(crate) fn set_texture_roi(ctx: &egui::Context, roi: Option<[f64; 4]>) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-tex-roi"), roi));
}
fn texture_roi(ctx: &egui::Context) -> Option<[f64; 4]> {
ctx.data(|d| d.get_temp::<Option<[f64; 4]>>(egui::Id::new("viewer-tex-roi"))).flatten()
}
/// The comp area ([x, y, w, h], comp pixels) the shown frame covers: its region of interest or
/// Extended Viewer area, else the whole `comp` (width, height).
pub(crate) fn shown_region(ctx: &egui::Context, comp: [f32; 2]) -> [f64; 4] {
texture_roi(ctx).unwrap_or([0.0, 0.0, comp[0] as f64, comp[1] as f64])
}
/// Show Snapshot: the stored snapshot is on screen (button or F5 held).
pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
let f5 = ctx.input(|i| i.key_down(egui::Key::F5)) && !ctx.egui_wants_keyboard_input();
app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5)
}
/// Show Channel and exposure, then the display colour conversion (View ▸ Use Display Color
/// Management / Simulate Output).
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, dc: Option<vw::DisplayColor>) -> egui::ColorImage {
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
if let Some(dc) = dc.filter(|_| matches!(ch, Channel::Rgb | Channel::RgbStraight)) {
dc.apply(&mut px);
}
vw::display_transform(&mut px, ch, colorized, stops);
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
}
/// Draw the frame into `comp_rect`: the rendered frame (covering the region of interest when one
/// is set), or the snapshot while Show Snapshot is on / F5 is held, through Show Channel and the
/// exposure. Fast Previews ▸ Wireframe draws layer outlines instead.
pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: &egui::Painter, comp_rect: Rect, cid: ItemId, ectx: &EvalCtx) {
let opts = app.session.state.viewer.clone();
let snap = showing_snapshot(app, ctx);
// Settings ▸ Video ▸ Mirror on Computer Monitor off: playback goes to Video Preview only.
if crate::prefs_live::main_viewer_hidden(app) && !snap {
painter.text(comp_rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Playing on Video Preview"), Tokens::ui(13.0), Color32::from_gray(150));
return;
}
if opts.fast_previews == FastPreviews::Wireframe && !snap {
let zoom = comp_rect.width() / ectx.comp.width.max(1) as f32;
let map = ViewerMap { origin: comp_rect.min, zoom, comp: [ectx.comp.width as f32, ectx.comp.height as f32], area: comp_rect };
for l in ectx.comp.layers.iter().filter(|l| l.is_active_at(ectx.time) && l.has_video()) {
let Some(b) = effectcraft_engine::render::content_bounds(ectx, l) else { continue };
let (m, _) = super::viewer::l2c(ectx, l);
let q: Vec<Pos2> = [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]]
.iter()
.map(|p| {
let c = m.apply(effectcraft_engine::geom::vec2(p[0], p[1]));
map.to_screen([c.x, c.y])
})
.collect();
painter.add(egui::Shape::closed_line(q, Stroke::new(1.0, app.tokens.label(l.label))));
}
return;
}
let dc = vw::DisplayColor::of(&app.session);
// The display conversion's inputs, for the texture caches.
let custom = if opts.simulation.profile == vw::SimProfile::MyCustom { format!("{:?}", app.session.prefs.custom_rgb) } else { String::new() };
let dc_key = format!("{:?}{:?}{}{custom}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile)
+ &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management);
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none();
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
if snap {
let Some(s) = app.session.snapshot.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-snapshot-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) };
let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), comp_rect, uv, Color32::WHITE);
painter.text(comp_rect.left_top() + vec2(6.0, 6.0), Align2::LEFT_TOP, crate::i18n::tr("Snapshot"), Tokens::ui(11.0), SNAP_COLOR);
return;
}
// A GPU frame (the web viewer): drawn straight from its texture; Show Channel / exposure
// read it back first (asynchronously in the browser: from the next frame on).
let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == n.0 && s.1 == n.1 && n.1.comp == cid.0);
let rect = if gpu {
let Some(id) = app.viewer_shown.map(|s| s.0) else { return };
if plain || app.viewer_pixels().is_none() {
if !plain {
ctx.request_repaint();
}
painter.image(id, comp_rect, uv, Color32::WHITE);
return;
}
comp_rect
} else {
let Some((tex, k)) = app.viewer_tex.clone() else { return };
if k.comp != cid.0 {
return;
}
let zoom = comp_rect.width() as f64 / ectx.comp.width.max(1) as f64;
let rect = match texture_roi(ctx) {
Some([x, y, w, h]) => Rect::from_min_size(comp_rect.min + vec2((x * zoom) as f32, (y * zoom) as f32), vec2((w * zoom) as f32, (h * zoom) as f32)),
None => comp_rect,
};
if plain {
painter.image(tex.id(), rect, uv, Color32::WHITE);
return;
}
rect
};
let Some(src) = app.viewer_image.clone() else { return };
let zoom_opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-display-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let tex = crate::frames::load_fitted(ctx, "viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
painter.image(tex.id(), rect, uv, Color32::WHITE);
}
// ---------------------------------------------------------------- bottom bar
/// A popup list above `anchor` (selected entries highlighted); entries register
/// `viewer.<auto>.<n>` automation ids.
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
let pid = egui::Id::new(id);
if !widgets::popup_is_open(ui, pid) {
return None;
}
let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::<f32>() + 14.0;
let seps = items.iter().filter(|(l, _)| l == "-").count();
widgets::popup_list(ui, pid, anchor.left_top() - vec2(0.0, h + 4.0), anchor, items.len().saturating_sub(seps), seps, |ui| {
ui.set_min_width(170.0);
let mut chosen = None;
for (i, (label, on)) in items.iter().enumerate() {
if label == "-" {
ui.separator();
continue;
}
let r = ui.selectable_label(*on, label.as_str());
app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label);
if r.clicked() {
chosen = Some(i);
}
}
chosen
})
}
fn toggle_popup(ui: &egui::Ui, id: &str) {
let pid = egui::Id::new(id).with("open");
let open: bool = ui.data(|d| d.get_temp(pid).unwrap_or(false));
ui.data_mut(|d| d.insert_temp(pid, !open));
}
/// Magnification as After Effects shows it: "136.1 %", "100 %".
pub(crate) fn magnification_label(pct: f32) -> String {
if (pct - pct.round()).abs() < 0.05 { format!("{:.0} %", pct.round()) } else { format!("{pct:.1} %") }
}
/// The resolution popup's label: the chosen factor, or the factor Auto resolves to in brackets.
pub(crate) fn resolution_label(res: Resolution, scale: f64) -> String {
match res {
Resolution::Auto => {
let r = if scale >= 0.99 {
"Full"
} else if scale >= 0.49 {
"Half"
} else if scale >= 0.33 {
"Third"
} else {
"Quarter"
};
format!("({r})")
}
Resolution::Custom(n) => format!("Custom ({n})"),
r => r.label().to_string(),
}
}
/// The Composition panel's bottom bar, in After Effects 2026 order: magnification, resolution,
/// transparency grid, mask and shape path visibility, region of interest, grid and guide
/// options, show channel, reset exposure, exposure, take / show snapshot, Fast Previews, the 3D
/// renderer and view, and the current time.
pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, zoom: f32, fit: f32, time: Tick, comp: &Comp) {
let t = app.tokens;
let p = ui.painter().clone();
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 cy = bar.center().y;
let mut x = bar.min.x + 8.0;
let ppp = ui.ctx().pixels_per_point();
// Magnification.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(80.0, 20.0));
if widgets::dropdown(ui, r, &magnification_label(zoom * ppp * 100.0), &t, egui::Id::new("vw-mag"))
.on_hover_text(crate::i18n::tr("Magnification ratio popup"))
.clicked()
{
toggle_popup(ui, "vw-mag-pop");
}
app.auto.add("viewer.magnification", r, "Magnification");
const MAGS: [&str; 15] = ["Fit", "Fit up to 100%", "-", "1.5%", "3.1%", "6.25%", "12.5%", "25%", "33.3%", "50%", "100%", "200%", "400%", "800%", "1600%"];
let cur = (zoom * ppp * 100.0 * 10.0).round() / 10.0;
let items: Vec<(String, bool)> = MAGS
.iter()
.map(|m| {
let on = match *m {
"Fit" => app.ui.viewer.zoom.is_none(),
s => s.trim_end_matches('%').parse::<f32>().is_ok_and(|v| app.ui.viewer.zoom.is_some() && (v - cur).abs() < 0.06),
};
(m.to_string(), on)
})
.collect();
if let Some(i) = popup(app, ui, "vw-mag-pop", r, &items, "magnification") {
app.ui.viewer.pan = [0.0, 0.0];
app.ui.viewer.zoom = match MAGS[i] {
"Fit" => None,
"Fit up to 100%" => Some(fit.min(1.0 / ppp)),
s => s.trim_end_matches('%').parse::<f32>().ok().map(|v| v / 100.0 / ppp),
};
}
x = r.max.x + 4.0;
// Resolution / down-sample factor.
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(84.0, 20.0));
let scale = app.viewer_scale(zoom, ppp);
if widgets::dropdown(ui, r, &resolution_label(app.ui.viewer.res, scale), &t, egui::Id::new("vw-res"))
.on_hover_text(crate::i18n::tr("Resolution/Down Sample Factor Popup"))
.clicked()
{
toggle_popup(ui, "vw-res-pop");
}
app.auto.add("viewer.resolution", r, "Resolution/Down Sample Factor");
let res = app.ui.viewer.res;
let items: Vec<(String, bool)> = vec![
("Full".into(), res == Resolution::Full),
("Half".into(), res == Resolution::Half),
("Third".into(), res == Resolution::Third),
("Quarter".into(), res == Resolution::Quarter),
("Custom...".into(), matches!(res, Resolution::Custom(_))),
("-".into(), false),
("Auto".into(), res == Resolution::Auto),
];
if let Some(i) = popup(app, ui, "vw-res-pop", r, &items, "resolutionItem") {
let ctx = ui.ctx().clone();
let id = ["view.res.full", "view.res.half", "view.res.third", "view.res.quarter", "view.res.custom", "", "view.res.auto"][i];
if let Err(e) = crate::menus::invoke(app, &ctx, id, json!({})) {
app.ui.status = e;
}
}
x = r.max.x + 6.0;
let tog = |ui: &mut egui::Ui, auto: &mut crate::automation::Registry, x: &mut f32, icon: Icon, on: bool, id: &str, tip: &str| -> (bool, Rect) {
let r = Rect::from_min_size(pos2(*x, cy - 11.0), vec2(22.0, 22.0));
let resp = widgets::icon_button(ui, r, icon, on, &t, egui::Id::new(("vw-btn", id))).on_hover_text(tip);
auto.add(&format!("viewer.{id}"), r, tip);
*x += 24.0;
(resp.clicked(), r)
};
if tog(ui, &mut app.auto, &mut x, Icon::Checker, app.ui.viewer.transparency_grid, "transparency", "Toggle Transparency Grid").0 {
app.ui.viewer.transparency_grid = !app.ui.viewer.transparency_grid;
}
if tog(ui, &mut app.auto, &mut x, Icon::MaskVis, app.ui.viewer.show_masks, "masks", "Toggle Mask and Shape Path Visibility").0 {
app.ui.viewer.show_masks = !app.ui.viewer.show_masks;
}
// Region of interest: with none, arm drawing (the next drag draws it); with one, clear it.
let has_roi = app.session.state.region_of_interest.is_some();
if tog(ui, &mut app.auto, &mut x, Icon::Region, has_roi || app.ui.viewer.roi_draw, "roi", "Region of Interest").0 {
if has_roi {
let _ = app.session.execute("view.setRegionOfInterest", json!({"rect": null}));
app.ui.viewer.roi_draw = false;
} else {
app.ui.viewer.roi_draw = !app.ui.viewer.roi_draw;
}
}
// Grid and guide options.
let v = &app.ui.viewer;
let any = v.grid || v.safe_margins || v.proportional_grid || (v.guides && !comp.guides.is_empty()) || v.rulers;
let (clicked, gr) = tog(ui, &mut app.auto, &mut x, Icon::Grid, any, "grid", "Choose grid and guide options");
if clicked {
toggle_popup(ui, "vw-grid-pop");
}
let v = &app.ui.viewer;
let items = vec![
("Title/Action Safe".to_string(), v.safe_margins),
("Proportional Grid".to_string(), v.proportional_grid),
("Grid".to_string(), v.grid),
("Guides".to_string(), v.guides),
("Rulers".to_string(), v.rulers),
("-".to_string(), false),
("3D Reference Axes".to_string(), app.session.prefs.three_d.show_reference_axes),
];
if let Some(i) = popup(app, ui, "vw-grid-pop", gr, &items, "gridItem") {
let v = &mut app.ui.viewer;
match i {
0 => v.safe_margins = !v.safe_margins,
1 => v.proportional_grid = !v.proportional_grid,
2 => v.grid = !v.grid,
3 => v.guides = !v.guides,
4 => v.rulers = !v.rulers,
6 => app.session.prefs.three_d.show_reference_axes = !app.session.prefs.three_d.show_reference_axes,
_ => {}
}
}
// Show Channel and Color Management Settings.
let vo = app.session.state.viewer.clone();
let (clicked, cr) = tog(ui, &mut app.auto, &mut x, Icon::Channel, vo.channel != Channel::Rgb, "channel", "Show Channel and Color Management Settings");
if clicked {
toggle_popup(ui, "vw-channel-pop");
}
let mut items: Vec<(String, bool)> = Channel::ALL.iter().map(|c| (c.label().to_string(), vo.channel == *c)).collect();
items.push(("-".into(), false));
items.push(("Colorize".into(), vo.colorized));
if let Some(i) = popup(app, ui, "vw-channel-pop", cr, &items, "channelItem") {
let params =
if i < Channel::ALL.len() { json!({"channel": Channel::ALL[i].id()}) } else { json!({"channel": vo.channel.id(), "colorized": !vo.colorized}) };
let _ = app.session.execute("view.channel", params);
}
// Exposure.
let exposed = vo.exposure != 0.0;
if tog(ui, &mut app.auto, &mut x, Icon::Exposure, exposed, "resetExposure", "Reset Exposure").0 {
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,
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("viewer.exposure", er, "Adjust Exposure");
if resp.changed() {
let _ = app.session.execute("view.exposure", json!({"stops": ev}));
}
x = er.max.x + 4.0;
// Snapshots.
if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
let scale = app.viewer_scale(zoom, ppp);
match app.session.execute("view.takeSnapshot", json!({"scale": scale.clamp(0.05, 1.0)})) {
Ok(_) => app.ui.status = "Snapshot taken".into(),
Err(e) => app.ui.status = e.to_string(),
}
}
let showing = showing_snapshot(app, ui.ctx());
let (_, sr) = tog(ui, &mut app.auto, &mut x, Icon::ShowSnapshot, showing, "showSnapshot", "Show Snapshot (hold)");
// Show Snapshot works while the button is held, like F5.
let held = ui.input(|i| i.pointer.primary_down()) && ui.input(|i| i.pointer.press_origin()).is_some_and(|o| sr.contains(o));
if app.session.snapshot.is_some() && held != app.session.state.viewer.show_snapshot {
let _ = app.session.execute("view.showSnapshot", json!({"value": held}));
}
// Fast Previews.
let fp = vo.fast_previews;
let (clicked, fr) = tog(ui, &mut app.auto, &mut x, Icon::Sparkle, fp != FastPreviews::Off || app.ui.viewer.fast_preview, "fastPreviews", "Fast Previews");
if clicked {
toggle_popup(ui, "vw-fast-pop");
}
let items: Vec<(String, bool)> = FastPreviews::ALL.iter().map(|m| (m.label().to_string(), fp == *m)).collect();
if let Some(i) = popup(app, ui, "vw-fast-pop", fr, &items, "fastPreviewsItem") {
let _ = app.session.execute("view.fastPreviewMode", json!({"mode": FastPreviews::ALL[i].id()}));
}
x += 4.0;
// 3D renderer + view.
if comp.has_3d() {
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(96.0, 20.0));
let _ = widgets::dropdown(ui, r, "Classic 3D", &t, egui::Id::new("vw-3d"));
app.auto.add("viewer.renderer3d", r, "3D Renderer");
x = r.max.x + 4.0;
let cid = app.session.active_comp_id();
let cur = cid.and_then(|c| app.session.state.views3d.get(&c)).map(|v| v.current).unwrap_or_default();
let r = Rect::from_min_size(pos2(x, cy - 10.0), vec2(118.0, 20.0));
if widgets::dropdown(ui, r, cur.label(), &t, egui::Id::new("vw-cam")).clicked() {
toggle_popup(ui, "vw-cam-pop");
}
app.auto.add("viewer.view3d", r, "3D View");
use effectcraft_engine::render::three_d::View3D;
let items: Vec<(String, bool)> = View3D::ALL.iter().map(|v| (v.label().to_string(), *v == cur)).collect();
if let Some(i) = popup(app, ui, "vw-cam-pop", r, &items, "view3dItem") {
let _ = app.session.execute("view.set3DView", json!({"view": View3D::ALL[i].id()}));
}
x = r.max.x + 4.0;
// Extended Viewer (Settings ▸ 3D): custom views and Draft 3D show past the comp frame.
let on = app.session.prefs.three_d.extended_viewer;
if tog(ui, &mut app.auto, &mut x, Icon::ExtendedViewer, on, "extendedViewer", "Extended Viewer").0 {
let _ = app.session.execute("view.extendedViewer", json!({}));
}
x += 2.0;
}
// Current time.
let tc = crate::panels::timecode(&app.session, comp, time);
let g = p.layout_no_wrap(tc, Tokens::mono(12.0), t.timecode);
let tr = Rect::from_min_size(pos2(x + 4.0, cy - 9.0), g.size() + vec2(8.0, 4.0));
p.galley(pos2(tr.min.x + 4.0, tr.min.y + 2.0), g, t.timecode);
let resp = ui.interact(tr, egui::Id::new("vw-timecode"), Sense::click());
app.auto.add("viewer.timecode", tr, "Current time");
crate::panels::toggle_time_display(app, &resp);
// Right: render time.
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
if tr.max.x + 50.0 < bar.max.x {
p.text(pos2(bar.max.x - 10.0, cy), Align2::RIGHT_CENTER, format!("{ms:.0} ms"), Tokens::ui(11.0), t.text_faint);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bar_labels_match_after_effects() {
assert_eq!(magnification_label(136.08), "136.1 %");
assert_eq!(magnification_label(100.0), "100 %");
assert_eq!(magnification_label(49.97), "50 %");
assert_eq!(resolution_label(Resolution::Auto, 1.0), "(Full)");
assert_eq!(resolution_label(Resolution::Auto, 0.5), "(Half)");
assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter");
assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)");
}
}