From dfff9ba2dc030287cbf303d565bb2bb2ddbd6cc8 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:55 +0200 Subject: [PATCH 1/3] Alt+B / Alt+Shift+B toggle layer sampling layer.sampling takes `toggle`: layers that all have the sampling already get the other one. The Bilinear and Bicubic menu entries, which carry the keys, pass it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer_menu.rs | 9 ++++++++- crates/engine/src/menus.rs | 4 ++-- crates/engine/src/tests_menu_cmds.rs | 24 ++++++++++++++++++++++++ crates/engine/src/tests_settings.rs | 4 ++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/commands/layer_menu.rs b/crates/engine/src/commands/layer_menu.rs index 755dd898..cac33088 100644 --- a/crates/engine/src/commands/layer_menu.rs +++ b/crates/engine/src/commands/layer_menu.rs @@ -76,6 +76,13 @@ fn sampling(s: &mut Session, p: &Value) -> Result { "bicubic" => Sampling::Bicubic, q => return Err(bad("layer.sampling", format!("sampling must be bilinear|bicubic, not `{q}`"))), }; + // `toggle`: layers that all have it already get the other one. + let all = s.project.comp(cid).is_some_and(|c| c.layers.iter().filter(|l| ids.contains(&l.id)).all(|l| l.switches.sampling == q)); + let q = match q { + Sampling::Bilinear if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bicubic, + Sampling::Bicubic if all && b_p(p, "toggle").unwrap_or(false) => Sampling::Bilinear, + q => q, + }; s.edit("Layer Sampling", None, |proj, _| { for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| ids.contains(&l.id)) { l.switches.sampling = q; @@ -800,7 +807,7 @@ fn sequence(s: &mut Session, p: &Value) -> Result { pub fn specs() -> Vec { vec![ cmd!("layer.quality", "Quality", [], None, "{layers?, quality: best|draft|wireframe}", has_layers, quality), - cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic}", has_layers, sampling), + cmd!("layer.sampling", "Sampling", [], None, "{layers?, sampling: bilinear|bicubic, toggle?}", has_layers, sampling), cmd!("layer.frameBlending", "Frame Blending", [], None, "{layers?, mode: off|frameMix|pixelMotion}", has_layers, frame_blending), cmd!("layer.hideOtherVideo", "Hide Other Video", ["Layer", "Switches"], Some("Cmd+Alt+Shift+V"), "{layers?}", has_layers, hide_other_video), cmd!("layer.showAllVideo", "Show All Video", ["Layer", "Switches"], None, "{comp?}", has_comp, show_all_video), diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 1fb215a3..47106540 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -785,8 +785,8 @@ Layer Draft | layer.quality {"quality":"draft"} | Cmd+Shift+U Wireframe | layer.quality {"quality":"wireframe"} | Cmd+Alt+Shift+U --- - Bilinear | layer.sampling {"sampling":"bilinear"} | Alt+B - Bicubic | layer.sampling {"sampling":"bicubic"} | Alt+Shift+B + Bilinear | layer.sampling {"sampling":"bilinear","toggle":true} | Alt+B + Bicubic | layer.sampling {"sampling":"bicubic","toggle":true} | Alt+Shift+B Switches Hide Other Video | layer.hideOtherVideo Show All Video | layer.showAllVideo diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index cb83c163..098bd856 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -52,6 +52,30 @@ fn layer_quality_sampling_frame_blending_and_undo() { assert!(s.execute("layer.quality", json!({"quality": "fancy"})).is_err()); } +/// `layer.sampling {toggle}` (Alt+B, Alt+Shift+B): layers that all have the sampling already get +/// the other one; without `toggle` it is only set. +#[test] +fn layer_sampling_toggles_when_every_layer_has_it() { + let mut s = comp(); + let a = solid(&mut s, "#ff0000"); + let b = solid(&mut s, "#00ff00"); + let sampling = |s: &Session| [layer(s, a).switches.sampling, layer(s, b).switches.sampling]; + let bicubic = json!({"layers": [a, b], "sampling": "bicubic", "toggle": true}); + s.execute("layer.sampling", bicubic.clone()).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); + s.execute("layer.sampling", bicubic.clone()).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bilinear; 2]); + s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bilinear", "toggle": true})).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); + // Mixed layers all get the one asked for. + s.execute("layer.sampling", json!({"layers": [a], "sampling": "bilinear"})).unwrap(); + s.execute("layer.sampling", bicubic).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); + // Without `toggle` it stays. + s.execute("layer.sampling", json!({"layers": [a, b], "sampling": "bicubic"})).unwrap(); + assert_eq!(sampling(&s), [Sampling::Bicubic; 2]); +} + /// `layer.setSwitch {others: true}` (Alt-click on an A/V feature): the layer stands alone, the /// other layers come back the next time. #[test] diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index fea7709e..dd68664d 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -418,8 +418,8 @@ fn default_keys_for_commands_that_had_none() { for (id, params, key) in [ ("edit.pasteReversedKeyframes", json!(null), "Cmd+Shift+V"), ("edit.purge", json!({"what": "snapshot"}), "Cmd+Shift+F5"), - ("layer.sampling", json!({"sampling": "bilinear"}), "Alt+B"), - ("layer.sampling", json!({"sampling": "bicubic"}), "Alt+Shift+B"), + ("layer.sampling", json!({"sampling": "bilinear", "toggle": true}), "Alt+B"), + ("layer.sampling", json!({"sampling": "bicubic", "toggle": true}), "Alt+Shift+B"), ("view.res.custom", json!(null), "Cmd+Alt+J"), ("file.setProxy", json!(null), "Cmd+Alt+P"), ("file.openRecent", json!(null), "Cmd+Alt+Shift+P"), From 498dbc0c715ceca99ea717d7a9e52ea72a7da0f0 Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:55 +0200 Subject: [PATCH 2/3] Show Channel: grayscale and colorized of one channel replace each other Alt+1 after Alt+Shift+1 shows the red channel in grayscale instead of going back to RGB (and the other way round). The same key again is still RGB. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/viewer_cmds.rs | 6 ++++-- crates/engine/src/tests_settings.rs | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index aac24267..0c3ed69d 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -127,8 +127,10 @@ fn snapping_options(s: &mut Session, p: &Value) -> Result { fn channel(s: &mut Session, p: &Value) -> Result { let name = str_p(p, "channel").unwrap_or("rgb"); let c = Channel::from_name(name).ok_or_else(|| bad("view.channel", format!("unknown channel `{name}` (rgb|red|green|blue|alpha|rgbStraight)")))?; - // Choosing the active single channel again returns to RGB, as in After Effects. - let c = if c == s.state.viewer.channel && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c }; + // Choosing the active single channel again returns to RGB; not when that changes it between + // grayscale and colorized. + let same = c == s.state.viewer.channel && b_p(p, "colorized").is_none_or(|v| v == s.state.viewer.colorized); + let c = if same && c != Channel::Rgb && b_p(p, "toggle").unwrap_or(false) { Channel::Rgb } else { c }; s.state.viewer.channel = c; if let Some(v) = b_p(p, "colorized") { s.state.viewer.colorized = v; diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs index dd68664d..458c886f 100644 --- a/crates/engine/src/tests_settings.rs +++ b/crates/engine/src/tests_settings.rs @@ -408,6 +408,14 @@ fn fast_previews_and_show_channel_modes_have_default_keys() { assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("green", false)); s.execute("view.channel.green", json!({})).unwrap(); assert_eq!(s.state.viewer.channel.id(), "rgb"); + // Grayscale and colorized of one channel replace each other; the same again is RGB. + s.execute("view.channel.redColorized", json!({})).unwrap(); + s.execute("view.channel.red", json!({})).unwrap(); + assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", false)); + s.execute("view.channel.redColorized", json!({})).unwrap(); + assert_eq!((s.state.viewer.channel.id(), s.state.viewer.colorized), ("red", true)); + s.execute("view.channel.redColorized", json!({})).unwrap(); + assert_eq!(s.state.viewer.channel.id(), "rgb"); } /// Default keys for commands and menu entries that had none. From 0eda17470685e1158fad626f89db7c482ec704cd Mon Sep 17 00:00:00 2001 From: pravdomil <2387356+pravdomil@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:55 +0200 Subject: [PATCH 3/3] Shift+; again zooms the Timeline back in Zoom to Fit Comp remembers the span it left and goes back to it when pressed again from the whole comp. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 15 +++++++++++++++ crates/ui-egui/src/state.rs | 4 ++++ crates/ui-egui/tests/ui_timeline_zoom.rs | 23 +++++++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a60e524a..51921e62 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -592,7 +592,7 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V crate::panels::timeline::zoom(app, ctx, k); return Ok(Value::Null); } - "timeline.zoomFit" => app.ui.timeline.pps = None, + "timeline.zoomFit" => crate::panels::timeline::toggle_fit_zoom(app, ctx), "timeline.zoomFrameToggle" => crate::panels::timeline::toggle_frame_zoom(app, ctx), "timeline.column" => { let col = params.get("column").and_then(Value::as_str).ok_or("timeline.column: need `column`")?; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 72f10429..69f7eabd 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -545,6 +545,21 @@ pub fn toggle_frame_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) { set_zoom(&mut app.ui.timeline, &comp, MAX_PPS, fit, cti, w as f64 / 2.0); } +/// `Shift+;`: show the whole comp; from there, go back to the span it was pressed in. +pub fn toggle_fit_zoom(app: &mut EffectcraftApp, ctx: &egui::Context) { + let tl = &mut app.ui.timeline; + if let Some(pps) = tl.pps { + tl.before_fit = Some((tl.start, pps)); + tl.pps = None; + tl.start = 0.0; + return; + } + let Some((start, pps)) = tl.before_fit.take() else { return }; + let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; + let Some(comp) = app.session.active_comp_arc() else { return }; + set_zoom(&mut app.ui.timeline, &comp, pps, fit_pps(w, &comp), start, 0.0); +} + /// Zoom the time ruler around the CTI. pub fn zoom(app: &mut EffectcraftApp, ctx: &egui::Context, k: f64) { let Some((_, w)) = ctx.data(|d| d.get_temp::<(f32, f32)>(egui::Id::new("timeline-graph-area"))) else { return }; diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d0a23279..56b5e2db 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -322,6 +322,9 @@ pub struct TimelineState { /// Visible span start (seconds) and pixels per second; None = fit comp. pub start: f64, pub pps: Option, + /// The span (`start`, `pps`) Zoom to Fit Comp left, which it goes back to when pressed again. + #[serde(skip)] + pub before_fit: Option<(f64, f64)>, pub scroll_y: f32, /// Left column area width. pub columns_w: f32, @@ -403,6 +406,7 @@ impl Default for TimelineState { TimelineState { start: 0.0, pps: None, + before_fit: None, scroll_y: 0.0, columns_w: 560.0, outline_scroll: 0.0, diff --git a/crates/ui-egui/tests/ui_timeline_zoom.rs b/crates/ui-egui/tests/ui_timeline_zoom.rs index 2c802600..a68ee0e0 100644 --- a/crates/ui-egui/tests/ui_timeline_zoom.rs +++ b/crates/ui-egui/tests/ui_timeline_zoom.rs @@ -163,6 +163,29 @@ fn semicolon_toggles_frame_level_and_the_whole_comp() { assert_eq!(h.state().ui.timeline.pps, None); } +/// `Shift+;` zooms out to the whole comp, and again back in to the span it left. +#[test] +fn shift_semicolon_toggles_the_whole_comp_and_the_span_before() { + let (mut h, _) = harness(); + h.state_mut().session.execute("time.set", json!({"time": 120.0})).unwrap(); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFrameToggle", json!({})).unwrap(); + h.run_steps(2); + let before = (h.state().ui.timeline.start, h.state().ui.timeline.pps); + assert!(before.1.is_some()); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap(); + assert_eq!(h.state().ui.timeline.pps, None); + h.run_steps(2); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap(); + assert_eq!((h.state().ui.timeline.start, h.state().ui.timeline.pps), before); + // With nothing to go back to, the whole comp stays. + h.state_mut().ui.timeline.pps = None; + h.state_mut().ui.timeline.before_fit = None; + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "timeline.zoomFit", json!({})).unwrap(); + assert_eq!(h.state().ui.timeline.pps, None); +} + fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) { h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); h.step();