diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 549e3ecb..8ba7f1a0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,6 +33,8 @@ kurbo = { workspace = true } image = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# Floating panels open in OS windows of their own on desktop; the web keeps them in the page. +craft-ui = { workspace = true, features = ["native-windows"] } sys-locale = { workspace = true } [dev-dependencies] diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 78519bf7..834c81ee 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -409,7 +409,7 @@ pub trait WorkspaceExt { fn panels(&self) -> Vec; } -fn node_panels(node: &Node, out: &mut Vec) { +pub(crate) fn node_panels(node: &Node, out: &mut Vec) { match node { Node::Split { first, second, .. } => { node_panels(first, out); diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 3281ef66..0f4ec0c0 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -226,17 +226,18 @@ impl EffectcraftApp { pos.and_then(|p| self.dock_rects.iter().rev().find(|(_, r)| r.contains(p)).map(|(k, _)| *k)).unwrap_or(self.ui.focused) } - /// Whether panel `p`'s floating window is recorded as its own native window. + /// Whether panel `p` is in a floating window with a desktop place: its own OS window on + /// desktop (craft-ui `native-windows`); on the web and in embedded viewports the window + /// stays in the page and the place is kept for a desktop session. pub fn in_panel_window(&self, p: PanelKind) -> bool { self.ui.dock.layout.floating.iter().any(|w| w.contains(&p) && w.native.is_some()) } - /// Move panel `p`'s floating window into its own native window, which can be placed on - /// another monitor, or back into the main window (`window` false). `None` toggles. A docked - /// panel is undocked first. The window's place is over the panel's place in the main window. - /// Returns whether `p` is now in a window of its own. - // D2: craft-ui's `native-windows` feature draws these windows; until then the place is - // recorded and the window is drawn in the main window. + /// Move panel `p` into a floating window of its own, which on desktop is an OS window that + /// can go on another monitor, or back into the main window (`window` false), where it joins + /// the first docked group. `None` toggles. A docked panel is undocked first, and its window + /// opens over the panel's place in the main window. Returns whether `p` is now in a window + /// of its own. pub fn set_panel_window(&mut self, ctx: &egui::Context, p: PanelKind, window: Option) -> Result { let current = self.in_panel_window(p); let window = window.unwrap_or(!current); @@ -244,7 +245,21 @@ impl EffectcraftApp { return Ok(current); } if !window { - self.apply_dock(Action::SetNative { panel: p, rect: None })?; + // On desktop every floating window is an OS window, so the way back is docking. + let anchor = self.ui.dock.layout.root.as_ref().and_then(|root| { + let mut panels = Vec::new(); + dock::node_panels(root, &mut panels); + panels.into_iter().find(|panel| *panel != p) + }); + let actions = match anchor { + Some(anchor) => vec![Action::Move { panel: p, anchor, placement: craft_ui::docking::Placement::Tab { before: None } }], + // Nothing is docked: the panel becomes the docked layout. + None => vec![Action::Close { panel: p }, Action::Open { panel: p, anchor: None }], + }; + let mut workspace = self.ui.dock.clone(); + // One transaction, so the panel is never reported closed and keeps its state. + let closed = workspace.apply_all(actions, dock::permissions).map_err(|_| format!("can't dock {}", p.title()))?; + self.commit_workspace(workspace, &closed, true); return Ok(false); } if !self.ui.dock.is_floating(p) { @@ -375,7 +390,11 @@ impl EffectcraftApp { } let style = dock_style(ui, &t); let maximized = self.ui.maximized.filter(|m| self.ui.dock.is_docked(*m)); - let mut area = DockArea::new(area_id()).float_bounds(body).maximized(maximized).locked(self.ui.dock.locked); + let mut area = DockArea::new(area_id()) + .float_bounds(body) + .maximized(maximized) + .locked(self.ui.dock.locked) + .input_blocked(self.dialog.is_some() || panels::dialogs::modal_open(&ctx)); if let Some(panel) = self.pending_panel_raise.take() { area = area.raise_panel(panel); } @@ -396,9 +415,10 @@ impl EffectcraftApp { menu: None, bodies: Vec::new(), }; - let output = area.show_content(&mut child, &layout, &style, &mut content); + let mut output = area.show_content(&mut child, &layout, &style, &mut content); let EffectContent { doc_actions, locks, menu, bodies, .. } = content; self.dock_rects = bodies; + self.forward_window_input(&ctx, std::mem::take(&mut output.forwarded_events), std::mem::take(&mut output.window_files)); if let Some(panel) = output.focused { self.focus_panel(&ctx, panel); } @@ -446,6 +466,20 @@ impl EffectcraftApp { panels::panel_menu_popup(self, ui); } + /// Keys and dropped files that reach a floating panel's own OS window go to the main + /// window's next frame ([`crate::EffectcraftApp::raw_input_hook`]), so its shortcuts and + /// file import handle them. Text is not forwarded, and nothing is while a text field in + /// the main window has the keyboard. + fn forward_window_input(&mut self, ctx: &egui::Context, events: Vec, files: Vec>) { + let typing = ctx.egui_wants_keyboard_input(); + let events = events.into_iter().filter(|event| !typing && !matches!(event, egui::Event::Text(_) | egui::Event::Ime(_))); + self.forwarded.events.extend(events); + self.forwarded.files.extend(files.into_iter().flat_map(|window| window.dropped)); + if !self.forwarded.events.is_empty() || !self.forwarded.files.is_empty() { + ctx.request_repaint(); + } + } + /// Automation ids for craft-ui's gesture sources: `panel.tab.`, `panel.drag.` /// (the group's drag bar), `panel.tabs.more.`, `panel.header.` (a stacked /// panel's open/close button), `panel.float..move`, `.close`, `.resize` (bottom-right diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 92e8d024..d1975994 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -278,6 +278,8 @@ pub struct EffectcraftApp { pub(crate) control_rx: Option>, pub(crate) deferred: Vec<(ControlRequest, f64)>, pub(crate) synthetic: Vec, + /// Input from floating panels' own OS windows for the main window's next frame. + pub(crate) forwarded: ForwardedInput, pub(crate) input_waiters: Vec>, /// Control requests waiting for an offloaded job (`wait: true` in the browser). pub(crate) job_waiters: Vec<(control::JobWaiter, Sender)>, @@ -381,6 +383,7 @@ impl EffectcraftApp { control_rx: None, deferred: vec![], synthetic: vec![], + forwarded: ForwardedInput::default(), input_waiters: vec![], job_waiters: vec![], queued_screenshots: vec![], @@ -1874,6 +1877,13 @@ impl EffectcraftApp { } } +/// Keys and dropped files from floating panels' OS windows (craft-ui `DockOutput`). +#[derive(Default)] +pub(crate) struct ForwardedInput { + pub(crate) events: Vec, + pub(crate) files: Vec, +} + impl eframe::App for EffectcraftApp { fn on_exit(&mut self) { self.save_workspace_state(); @@ -1885,6 +1895,8 @@ impl eframe::App for EffectcraftApp { } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + raw_input.events.append(&mut self.forwarded.events); + raw_input.dropped_files.append(&mut self.forwarded.files); let injected = !self.synthetic.is_empty(); self.feed_synthetic(raw_input); if pointer_wrap::prepare_input(ctx, raw_input, injected) { diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 07b257e2..d224d1a0 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -170,6 +170,18 @@ pub(crate) fn modal_space(ctx: &egui::Context) -> egui::Vec2 { (ctx.content_rect().size() - vec2(48.0, 48.0)).max(vec2(120.0, 80.0)) } +fn modal_pass_id() -> egui::Id { + egui::Id::new("effectcraft-modal-pass") +} + +/// Whether a modal dialog showed in this pass or the one before: egui's `Modal` blocks input +/// only in its own window, so floating panels in OS windows of their own are blocked by the +/// dock area while this holds. Dialogs draw after the dock, so the previous pass counts. +pub(crate) fn modal_open(ctx: &egui::Context) -> bool { + let shown = ctx.data(|d| d.get_temp::(modal_pass_id())); + shown.is_some_and(|pass| pass.saturating_add(1) >= ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT)) +} + pub(crate) fn modal(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui)) { modal_with_scroll(ctx, title, size, t, false, body); } @@ -190,6 +202,8 @@ fn modal_with_scroll(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tok /// only its content: `body` gets the height left under the title, with `size` kept within the /// window. pub(crate) fn modal_fit(ctx: &egui::Context, title: &str, size: egui::Vec2, t: &Tokens, body: impl FnOnce(&mut egui::Ui, f32)) { + let pass = ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT); + ctx.data_mut(|d| d.insert_temp(modal_pass_id(), pass)); let size = size.min(modal_space(ctx)); let id = egui::Id::new(("modal", title)); // Dragging the title row moves the dialog off what it edits. The offset is kept within the @@ -687,6 +701,29 @@ fn palette(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { mod tests { use super::*; + #[test] + fn modal_open_holds_from_a_shown_dialog_until_the_pass_after_it_closes() { + let (app, ctx) = app(); + let t = app.tokens; + // Asked as the dock asks it: during the pass, before the dialogs draw. + let pass = |show: bool| { + let mut open = false; + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + open = modal_open(ui.ctx()); + if show { + modal(ui.ctx(), "Test", vec2(200.0, 100.0), &t, |_| {}); + } + }); + output.textures_delta.clear(); + open + }; + assert!(!pass(false)); + assert!(!pass(true), "the first pass of a dialog finds none shown yet"); + assert!(pass(true)); + assert!(pass(false), "the pass after the last one shown still counts"); + assert!(!pass(false)); + } + fn app() -> (EffectcraftApp, egui::Context) { let ctx = egui::Context::default(); let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 50ecf2f7..9ee05e61 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -234,9 +234,8 @@ pub fn panel_menu_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui) { let _ = app.dock_command(ui.ctx(), craft_ui::docking::Action::Float { panel, rect: r }); close = true; } - // Multiple monitors: a native window of its own, which can go on another screen. - // D2: craft-ui's native windows draw it; until then the place is recorded and the - // panel floats in the main window. + // Multiple monitors: an OS window of its own, which can go on another screen. Where + // egui embeds viewports (the web), floating windows stay in the page. if !ui.ctx().embed_viewports() { let windowed = app.in_panel_window(panel); if ui.button(if windowed { tr("Move Panel to Main Window") } else { tr("Move Panel to New Window") }).clicked() { diff --git a/crates/ui-egui/tests/it/ui_panel_windows.rs b/crates/ui-egui/tests/it/ui_panel_windows.rs index 80a3f5ee..d2750bb0 100644 --- a/crates/ui-egui/tests/it/ui_panel_windows.rs +++ b/crates/ui-egui/tests/it/ui_panel_windows.rs @@ -1,7 +1,7 @@ -//! Multiple monitors (#486): Move Panel to New Window records a floating panel's window as its -//! own native window, which can be placed on another screen, and back. -// D2: craft-ui's `native-windows` feature draws these windows; until then they are drawn in the -// main window and only their place is recorded and saved. +//! Multiple monitors (#486): Move Panel to New Window puts a panel in a floating window of its +//! own, which on desktop is an OS window that can go on another screen, and Move Panel to Main +//! Window docks it again. The test harness embeds viewports, so these windows draw in the main +//! window here, as on the web. use effectcraft_engine::Session; use effectcraft_ui_egui::EffectcraftApp; use effectcraft_ui_egui::dock::{PanelKind, WorkspaceExt as _}; @@ -31,9 +31,10 @@ fn panel_window_command_moves_a_docked_panel_into_its_own_window_and_back() { // Asking again changes nothing; no `window` toggles. assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project", "window": true})).unwrap(), json!({"window": true})); assert_eq!(invoke(&mut app, &ctx, json!({"panel": "Project"})).unwrap(), json!({"window": false})); - // Back in the main window, still floating where it was. + // Back in the main window: docked in the first docked group. assert!(!app.in_panel_window(PanelKind::Project)); - assert!(app.ui.dock.is_floating(PanelKind::Project)); + assert!(!app.ui.dock.is_floating(PanelKind::Project)); + assert!(app.ui.dock.is_docked(PanelKind::Project)); // The serialized panel form works too. assert_eq!(invoke(&mut app, &ctx, json!({"panel": PanelKind::Project})).unwrap(), json!({"window": true})); // Bad requests are errors and change nothing. @@ -65,3 +66,41 @@ fn panel_windows_survive_restart_with_hostile_places_made_safe() { assert!(x <= 100_000.0 && y >= -100_000.0 && (160.0..=16_384.0).contains(&w) && (100.0..=16_384.0).contains(&h)); assert!(sanitized_window_rect([f32::NAN, f32::INFINITY, f32::NAN, f32::NEG_INFINITY]).iter().all(|v| v.is_finite())); } + +#[test] +fn a_click_on_a_floating_panel_does_nothing_while_a_dialog_is_open() { + use egui::{Event, Modifiers, PointerButton, pos2, vec2}; + use egui_kittest::Harness; + let mut app = EffectcraftApp::new(Session::default()); + app.ui.start_screen = false; + app.apply_dock(craft_ui::docking::Action::Float { panel: PanelKind::Project, rect: [80.0, 120.0, 320.0, 240.0] }).unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app); + h.run_steps(3); + let close = |h: &Harness<'_, EffectcraftApp>| { + let e = h.state().auto.elements.iter().find(|e| e.id.starts_with("panel.float.") && e.id.ends_with(".close")).expect("the floating window's ×"); + pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0) + }; + let click = |h: &mut Harness<'_, EffectcraftApp>, pos| { + h.event(Event::PointerMoved(pos)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + }; + let at = close(&h); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "general"})).unwrap(); + h.run_steps(3); + let before = h.state().ui.dock.clone(); + click(&mut h, at); + assert_eq!(h.state().ui.dock, before, "the floating panel takes no click while Settings is open"); + assert!(h.state().ui.dock.is_floating(PanelKind::Project)); + // The same click closes the window once the dialog is gone. + h.state_mut().dialog = None; + h.run_steps(3); + let at = close(&h); + click(&mut h, at); + assert!(!h.state().ui.dock.is_open(PanelKind::Project)); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 167f8e88..49cdca8e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -216,9 +216,16 @@ takes a name, or as `{"Viewer":42}` and `{"ScriptPanel":77}` for viewers and Scr side zone docks along that edge of the whole workspace. `window.floatPanel {panel, rect?}` requires exactly four finite numbers when `rect` is given and otherwise floats a 420x320 window in the middle of the app window. `window.closePanel {panel}` closes it. -- `window.panelWindow {panel, window?}` records a floating panel's window as its own native - window (`window` false moves it back; without `window` it toggles). A docked panel floats - first. The window is drawn in the main window in this version. +- `window.panelWindow {panel, window?}` puts a panel in a floating window of its own with a + desktop place over the panel's place in the main window, and returns `{"window": bool}`. A + docked panel floats first. `window` false docks it again, as a tab of the first docked group; + without `window` it toggles. + +On desktop every floating window is an OS window of its own (craft-ui's `native-windows`), which +can go on another monitor; on Wayland, on the web and where egui embeds viewports, floating +windows stay in the main window. Keys pressed in a floating window that none of its widgets uses +run the main window's shortcuts, and files dropped on one import as on the main window. While a +modal dialog is open, floating windows take no input. The automation ids of the docking chrome are `panel.tab.`, `panel.drag.` (a group's drag bar), `panel.tabs.more.`, `panel.menu.` (≡), `panel.header.` @@ -229,5 +236,11 @@ group's drag bar), `panel.tabs.more.`, `panel.menu.` (≡), `panel `s`) and `dock.corner..` where two dividers meet. Saved workspaces (`workspaces.json`, version 2) hold craft-ui `Workspace`s: the layout, the -closed panels with where each goes back, and the lock. A file from an earlier version loads as -the default layout and is left unchanged. +closed panels with where each goes back, the lock, and each floating window's desktop place. A +version 1 file (EffectCraft 0.10 and earlier) and the older unversioned record of +named workspaces load with every panel in place: the current layout, the named workspaces, +floating windows and their native window places. A stacked frame with several tabs becomes one +stacked panel per tab, the shown one keeping the frame's open state and height. Panels that were +closed stay closed and reopen at their default place. The first save writes version 2 and keeps +the earlier file as `workspaces.old.json`. A damaged, unknown or newer file loads as the default +layout and is left unchanged. diff --git a/docs/gaps.md b/docs/gaps.md index f8fcd64c..dc2af3cd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -76,7 +76,7 @@ are grouped by area; the issue tracker has the rest. | 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | -| 20 | **Multi-monitor and video output** | #486: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | +| 20 | **Multi-monitor and video output** | #486: on desktop every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen; tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) | | 21 | **Small 26.x features** | Proportional Scrubbing (26.2), pasting Illustrator's own clipboard data as shapes (26.3; SVG markup on the clipboard pastes as a shape layer, #738), variable-font filter (26.3), the rebuilt Effect Controls (26.5). Percentage guides (View ▸ Add Percentage Guide, Convert Guides to Percentage / Pixels) and effect colour labels (Effect Controls header bar and right-click Label menu, `effect.setLabel`) are in (#1095), built from the release notes' one-line descriptions and never compared with After Effects: the Timeline's effect rows wear the label as a colour bar on the left edge (#1509), but After Effects' exact UI there is unknown | Users of the current version expect them | 10–20 h left | — · [ui-parity.md](ui-parity.md) | | 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372 closed by #778: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) | | 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 and #611 closed | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index df4c5e0e..55c6b5cd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -16,7 +16,7 @@ feel and platform differences, and the 26.x interface changes are not in yet. | Area | Status | % | Evidence and gaps | |---|---|---:|---| | Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) | -| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | +| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; on desktop floating panels are OS windows of their own on any screen, with tabs and groups dragging between windows and shortcuts working in them (#486); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed | | Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a hidden null, camera or light has no box or handles in the viewer and isn't picked there; other selected hidden layers keep their layer controls and paths, #1769); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849); a shape group's inner Contents (After Effects' elided Vectors group) draws no row — the group's path, fill/stroke and Transform list directly under it | | Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu | | Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |