diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 9ba6ed55e..ab8baace3 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -159,7 +159,9 @@ impl EffectcraftApp { panels::precomp::navigator(self, &mut child, nav); content.min.y = nav.max.y; } - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id()))); + let mut child = ui.new_child( + egui::UiBuilder::new().max_rect(content).id_salt(("panel", p.id())).ui_stack_info(egui::UiStackInfo::default().with_tag(crate::widgets::PANEL_TAG)), + ); child.set_clip_rect(content.intersect(ui.clip_rect())); panels::show(self, &mut child, p, content); if viewer && !self.ui.start_screen { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 003cb9ad4..8f12e52be 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -6,7 +6,49 @@ use crate::automation::Registry; use crate::icons::{self, Icon}; use crate::theme::Tokens; -/// "Hot text": a blue number you drag horizontally to scrub, click to type. +/// The [`egui::UiStackInfo`] tag of a dock panel's `Ui`: Tab moves between the hot-text fields +/// of one panel. +pub const PANEL_TAG: &str = "effectcraft.panel"; + +/// A panel's hot-text fields in drawing order (this pass's and the last one's), and a Tab +/// pressed while typing in one: (that field, Shift held, the pass). +#[derive(Clone, Default)] +struct TabOrder { + pass: u64, + current: Vec, + last: Vec, + pending: Option<(egui::Id, bool, u64)>, +} + +fn tab_order_id(ui: &Ui) -> egui::Id { + let panel = ui.stack().iter().find(|s| s.tags().contains(PANEL_TAG)).map_or(egui::Id::NULL, |s| s.id); + egui::Id::new(("hot-tab-order", panel)) +} + +/// Puts hot-text field `id` in its panel's Tab order. True when a Tab in the field before it +/// (Shift+Tab: after it) moves the typing here. +fn tab_stop(ui: &Ui, id: egui::Id) -> bool { + let key = tab_order_id(ui); + let pass = ui.ctx().cumulative_pass_nr(); + ui.data_mut(|d| { + let o = d.get_temp_mut_or_default::(key); + if o.pass != pass { + o.last = std::mem::take(&mut o.current); + o.pass = pass; + } + o.current.push(id); + let Some((from, back, at)) = o.pending else { return false }; + let i = o.last.iter().position(|f| *f == from); + let next = i.and_then(|i| if back { i.checked_sub(1) } else { i.checked_add(1) }).and_then(|i| o.last.get(i)).copied(); + if next == Some(id) || next.is_none() || pass > at.saturating_add(2) { + o.pending = None; + } + next == Some(id) + }) +} + +/// "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, @@ -20,16 +62,28 @@ pub fn hot_number_at( t: &Tokens, ) -> (Rect, Option, bool) { let editing_id = id.with("editing"); - let editing: Option = ui.data(|d| d.get_temp(editing_id)); + let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); + if tab_stop(ui, id) && editing.is_none() { + editing = Some(format!("{value:.decimals$}")); + } let text = format!("{value:.decimals$}{suffix}"); let galley = ui.painter().layout_no_wrap(text, Tokens::ui(12.0), t.hot_text); let rect = Rect::from_min_size(rect_min, galley.size() + vec2(4.0, 4.0)); if let Some(mut buf) = editing { let er = Rect::from_min_size(rect_min - vec2(2.0, 1.0), vec2((galley.size().x + 24.0).max(54.0), 18.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(er)); - let r = child.add(egui::TextEdit::singleline(&mut buf).desired_width(er.width()).font(Tokens::ui(12.0)).margin(egui::Margin::symmetric(2, 1))); + // (Tab stays with the field: it moves to the next hot-text field, not the next widget.) + let r = child.add( + egui::TextEdit::singleline(&mut buf) + .id(id.with("text")) + .desired_width(er.width()) + .font(Tokens::ui(12.0)) + .margin(egui::Margin::symmetric(2, 1)) + .lock_focus(true), + ); + let tab = r.has_focus().then(|| ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab).then_some(i.modifiers.shift))).flatten(); let mut out = None; - if r.lost_focus() { + if r.lost_focus() || tab.is_some() { let txt = buf.trim().trim_end_matches(suffix).trim().replace(',', "."); if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && let Ok(v) = txt.parse::() @@ -38,6 +92,11 @@ pub fn hot_number_at( out = Some(v.clamp(range.0, range.1)); } ui.data_mut(|d| d.remove::(editing_id)); + if let Some(back) = tab { + r.surrender_focus(); + let (key, pass) = (tab_order_id(ui), ui.ctx().cumulative_pass_nr()); + ui.data_mut(|d| d.get_temp_mut_or_default::(key).pending = Some((id, back, pass))); + } } else { keep_focus(ui.ctx(), &r, &buf); ui.data_mut(|d| d.insert_temp(editing_id, buf)); @@ -97,11 +156,13 @@ pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, s /// Keep the keyboard on an inline editor that is open until it loses the focus. The frame it /// opens (it has no focus yet) its whole `text` is selected, so typing replaces the value. +/// (Only then is the focus requested: a request resets what keys the field keeps for itself, +/// such as Tab with [`egui::TextEdit::lock_focus`].) pub fn keep_focus(ctx: &egui::Context, field: &Response, text: &str) { if !ctx.memory(|m| m.has_focus(field.id)) { select_all(ctx, field.id, text); + field.request_focus(); } - field.request_focus(); } /// Select all of the text field `id`'s `text`, so typing replaces it. diff --git a/crates/ui-egui/tests/ui_inline_edit.rs b/crates/ui-egui/tests/ui_inline_edit.rs index 2562f64e1..420e9635c 100644 --- a/crates/ui-egui/tests/ui_inline_edit.rs +++ b/crates/ui-egui/tests/ui_inline_edit.rs @@ -262,6 +262,74 @@ fn the_expression_field_grows_while_editing_and_drags_taller() { assert_eq!(h.state().ui.timeline.expr_lines.get(&opacity), Some(&1)); } +fn key_with(h: &mut Harness<'_, EffectcraftApp>, key: Key, modifiers: Modifiers) { + h.input_mut().events.push(Event::ModifiersChanged(modifiers)); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + h.step(); + h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); +} + +/// #361: Tab in a value being typed commits it and types in the next value (the next dimension, +/// then the next property's), Shift+Tab the previous, in the Timeline and in Effect Controls, +/// as in After Effects. It used to drop the keyboard instead. +#[test] +fn tab_commits_a_value_and_moves_to_the_next() { + let (mut h, _) = harness(); + let (layer, transform) = { + let s = &mut h.state_mut().session; + let l = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [l]})).unwrap(); + for fx in ["Point Control", "Slider Control"] { + s.execute("effect.apply", json!({"layers": [l], "effect": fx})).unwrap(); + } + let tr = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(l)).unwrap().props.sub("transform").unwrap().uid; + (l, tr) + }; + h.state_mut().ui.timeline.open_layers.insert(layer); + h.state_mut().ui.timeline.open_groups.insert(transform); + h.state_mut().show_panel(effectcraft_ui_egui::dock::PanelKind::EffectControls); + h.run_steps(3); + let props = + |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(layer)).unwrap().props.clone(); + let value = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().value.components(); + let uid = |h: &Harness<'_, EffectcraftApp>, path: &str| props(h).prop(path).unwrap().uid; + let (position, scale) = (uid(&h, "transform/position"), uid(&h, "transform/scale")); + let none = Modifiers::NONE; + // Timeline: Position X, Tab, Position Y, Tab, Scale (linked). + let p = center(&h, &format!("timeline.prop.{position}.value.0")); + click(&mut h, p, 1); + type_text(&mut h, "100"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "50"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "200"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "transform/position")[..2], [100.0, 50.0]); + assert_eq!(value(&h, "transform/scale")[..2], [200.0, 200.0]); + assert!(!h.ctx.egui_wants_keyboard_input(), "Enter ends the typing"); + // Shift+Tab: from Scale X back to Position Y. + let p = center(&h, &format!("timeline.prop.{scale}.value.0")); + click(&mut h, p, 1); + type_text(&mut h, "120"); + key_with(&mut h, Key::Tab, Modifiers::SHIFT); + type_text(&mut h, "60"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "transform/position")[..2], [100.0, 60.0]); + assert_eq!(value(&h, "transform/scale")[..2], [120.0, 120.0]); + // Effect Controls: Point Control's Y, Tab, Slider Control's Slider (the next effect). + let point = uid(&h, "effects/#1/point"); + let p = center(&h, &format!("effectControls.prop.{point}.value.1")); + click(&mut h, p, 1); + type_text(&mut h, "7"); + key_with(&mut h, Key::Tab, none); + type_text(&mut h, "42"); + key(&mut h, Key::Enter); + assert_eq!(value(&h, "effects/#1/point")[1], 7.0); + assert_eq!(value(&h, "effects/#2/slider"), [42.0]); +} + /// A click at `p` with `modifiers` held. fn click_with(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, modifiers: Modifiers) { h.input_mut().events.push(Event::PointerMoved(p)); diff --git a/docs/parity.md b/docs/parity.md index 9f3ee66d4..64fddba9d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -171,7 +171,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Web | 98% | 0.1 | No shared-memory threads inside one engine instance (the decided design is one engine instance per worker; a threaded build needs nightly `build-std`) (M13.30: Render Queue, analyses and Content-Aware Fill render on each job worker's own WebGPU device in passes, particles and Advanced 3D read back under keys, layer buffers in the browser's disk cache; browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10; WEB-1 in M13.24: GPU effects and the GPU compositor in the frame workers on their own WebGPU devices with deferred readbacks (frames render in passes; Backend Auto per comp), the disk cache in the Origin Private File System (written by the workers with sync access handles, LRU under the settings' limit, served after a reload), the storage manager (Settings ▸ Disk ▸ Browser Storage, `storage.info` / `storage.persist` / `storage.clear`), and a WGSL constant Chrome rejected (it disabled every GPU kernel in Chrome) fixed) | | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | -| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | +| Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |