Merge pull request #391 from storytold/fix/279-275-284-ui
Menu-bar keyboard, comp marker bin, UI Scale and Settings scroll (#279, #275, #284)
This commit is contained in:
commit
d5f78f3fc0
|
|
@ -25,6 +25,9 @@ pub const PREFS_FILE: &str = "prefs.json";
|
|||
/// Settings ▸ General ▸ Language: (label, `general.language` value).
|
||||
pub const LANGUAGES: &[(&str, &str)] = &[("Match System", "system"), ("English", "en"), ("日本語", "ja"), ("简体中文", "zh-hans"), ("繁體中文", "zh-hant")];
|
||||
|
||||
/// Settings ▸ Appearance ▸ UI Scale: (label, `appearance.uiScale` percent).
|
||||
pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%", "125"), ("150%", "150"), ("175%", "175"), ("200%", "200")];
|
||||
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
|
||||
|
||||
|
|
@ -140,6 +143,8 @@ page!(Appearance {
|
|||
theme: String = "dark".into(),
|
||||
/// User interface brightness, -1 (darker) … 1 (lighter).
|
||||
brightness: f64 = 0.0,
|
||||
/// The size of the whole interface in percent (75–200), on top of the display's own scale.
|
||||
ui_scale: u32 = 100,
|
||||
use_label_color_for_handles: bool = true,
|
||||
use_label_color_for_tabs: bool = true,
|
||||
cycle_mask_colors: bool = true,
|
||||
|
|
@ -454,6 +459,7 @@ impl Prefs {
|
|||
a.interval_minutes = a.interval_minutes.clamp(1, 240);
|
||||
a.max_versions = a.max_versions.clamp(1, 99);
|
||||
self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);
|
||||
self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200);
|
||||
let defaults = default_labels();
|
||||
self.labels.truncate(defaults.len());
|
||||
for d in defaults.iter().skip(self.labels.len()) {
|
||||
|
|
@ -1009,6 +1015,7 @@ pub fn pages() -> Vec<Page> {
|
|||
items: vec![
|
||||
s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true),
|
||||
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
|
||||
Section("Labels and Colors"),
|
||||
s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
|
||||
s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
|
||||
|
|
|
|||
|
|
@ -87,6 +87,28 @@ fn prefs_set_validates_keys_types_and_ranges() {
|
|||
assert!(p.reset(Some("bogus")).is_err());
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200;
|
||||
/// the dialog's choices set it as a number.
|
||||
#[test]
|
||||
fn ui_scale_is_a_percentage_from_75_to_200() {
|
||||
let mut p = Prefs::default();
|
||||
assert_eq!(p.appearance.ui_scale, 100);
|
||||
p.set("appearance.uiScale", json!("150")).unwrap();
|
||||
assert_eq!(p.appearance.ui_scale, 150);
|
||||
assert_eq!(p.get("appearance.uiScale"), Some(json!(150)));
|
||||
p.set("appearance.uiScale", json!(500)).unwrap();
|
||||
assert_eq!(p.appearance.ui_scale, 200);
|
||||
p.set("appearance.uiScale", json!(10)).unwrap();
|
||||
assert_eq!(p.appearance.ui_scale, 75);
|
||||
let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap();
|
||||
let listed = page.items.iter().any(|i| {
|
||||
matches!(i, crate::prefs::Item::Setting { key: "appearance.uiScale", kind: crate::prefs::Kind::Choice(c), live: true, .. } if c.contains(&("200%", "200")))
|
||||
});
|
||||
assert!(listed, "Settings ▸ Appearance offers it");
|
||||
// Older settings files without it get the default.
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_key_exists_and_docs_list_the_todo_settings() {
|
||||
let p = Prefs::default();
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ pub enum Icon {
|
|||
Keyframe,
|
||||
PickWhip,
|
||||
Flowchart,
|
||||
/// The Timeline's composition marker bin: a marker tab resting in an open tray.
|
||||
MarkerBin,
|
||||
// project items
|
||||
Folder,
|
||||
Comp,
|
||||
|
|
@ -412,6 +414,10 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
|||
pen.rect(10.0, 9.5, 14.5, 13.5);
|
||||
pen.line(&[(6.0, 4.5), (12.25, 4.5), (12.25, 9.5)]);
|
||||
}
|
||||
MarkerBin => {
|
||||
pen.fill(&[(5.0, 2.0), (11.0, 2.0), (11.0, 7.5), (8.0, 10.5), (5.0, 7.5)]);
|
||||
pen.line(&[(2.0, 9.0), (2.0, 14.0), (14.0, 14.0), (14.0, 9.0)]);
|
||||
}
|
||||
Folder => pen.closed(&[(1.5, 4.0), (6.0, 4.0), (7.5, 5.5), (14.5, 5.5), (14.5, 13.0), (1.5, 13.0)]),
|
||||
Comp => {
|
||||
pen.rect(2.0, 3.0, 14.0, 13.0);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ pub mod gpu_failure;
|
|||
pub mod header;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
pub mod menu_keys;
|
||||
pub mod menus;
|
||||
pub mod native_menu;
|
||||
pub mod panels;
|
||||
|
|
@ -372,6 +373,10 @@ impl EffectcraftApp {
|
|||
theme::apply_visuals(ctx, &self.tokens);
|
||||
let tips = p.general.show_tool_tips;
|
||||
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
|
||||
// Settings ▸ Appearance ▸ UI Scale (#284). egui's own Ctrl+= / Ctrl+- / Ctrl+0 zoom stays
|
||||
// off: Ctrl+0 is Window ▸ Project, and the scale is a setting.
|
||||
ctx.options_mut(|o| o.zoom_with_keyboard = false);
|
||||
ctx.set_zoom_factor(p.appearance.ui_scale as f32 / 100.0);
|
||||
self.ui.cache_when_idle = p.previews.cache_frames_when_idle;
|
||||
self.ui.viewer.fast_preview = p.previews.fast_previews;
|
||||
self.frames.set_budget(p.cache_budgets(self.session.sys_memory).preview);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,327 @@
|
|||
//! The keyboard in the in-window menu bar (#279), as in After Effects on Windows: Alt pressed and
|
||||
//! released on its own focuses the menu bar (and leaves it again); Left / Right move between its
|
||||
//! menus, Down, Enter or Space opens one. In an open menu Up / Down move the highlight through
|
||||
//! the entries (skipping disabled ones, wrapping around), Right opens the highlighted submenu and
|
||||
//! Left closes it again (in the top menu they open the next / previous menu), Enter or Space
|
||||
//! chooses the highlighted entry and Escape closes the menus. The highlight is egui's keyboard
|
||||
//! focus, so Enter and Space are egui's own clicks on it.
|
||||
//!
|
||||
//! While a menu is open or the bar has the keyboard, keys go to the menus only: the app's
|
||||
//! shortcuts and the panels don't see them (Space must not start a preview).
|
||||
|
||||
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
|
||||
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui};
|
||||
|
||||
/// An entry of an open menu as drawn this frame.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Row {
|
||||
id: Id,
|
||||
enabled: bool,
|
||||
submenu: bool,
|
||||
}
|
||||
|
||||
/// An open menu (or submenu) as drawn this frame.
|
||||
#[derive(Default)]
|
||||
struct Level {
|
||||
/// The menu's root `Ui` (where egui keeps which submenu is open).
|
||||
menu: Option<Id>,
|
||||
/// The submenu entry it opened from (none for a top-level menu).
|
||||
owner: Option<Id>,
|
||||
/// Drawn only to measure it (its first frame): its entries can't take the highlight yet.
|
||||
sizing: bool,
|
||||
rows: Vec<Row>,
|
||||
}
|
||||
|
||||
/// The menu bar's buttons and open menus, collected while they are drawn.
|
||||
#[derive(Default)]
|
||||
pub struct Nav {
|
||||
/// The bar's menu buttons and their menus' popups.
|
||||
tops: Vec<(Id, Id)>,
|
||||
/// Open menus by depth: the top-level menu, then each open submenu.
|
||||
levels: Vec<Level>,
|
||||
}
|
||||
|
||||
/// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the
|
||||
/// menu at this depth.
|
||||
#[derive(Clone, Copy)]
|
||||
struct FirstEntry(usize);
|
||||
|
||||
/// Submenus opened from the keyboard, with where the pointer was: egui closes an open submenu
|
||||
/// while the pointer rests on another entry of its menu, so they stay open until it moves.
|
||||
#[derive(Clone, Default)]
|
||||
struct KeyOpened {
|
||||
submenus: Vec<(Id, Id)>,
|
||||
pointer: Option<Pos2>,
|
||||
}
|
||||
|
||||
fn pending_id() -> Id {
|
||||
Id::new("menu-keys-first")
|
||||
}
|
||||
fn opened_id() -> Id {
|
||||
Id::new("menu-keys-opened")
|
||||
}
|
||||
fn active_id() -> Id {
|
||||
Id::new("menu-keys-active")
|
||||
}
|
||||
fn alt_id() -> Id {
|
||||
Id::new("menu-keys-alt")
|
||||
}
|
||||
|
||||
impl Nav {
|
||||
/// A top-level menu button of the bar.
|
||||
pub fn top(&mut self, button: &Response) {
|
||||
self.tops.push((button.id, Popup::default_response_id(button)));
|
||||
}
|
||||
|
||||
/// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves
|
||||
/// the keyboard highlight to it, and a highlighted entry scrolls into view.
|
||||
pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) {
|
||||
while self.levels.len() <= depth {
|
||||
self.levels.push(Level::default());
|
||||
}
|
||||
let Some(level) = self.levels.get_mut(depth) else { return };
|
||||
if level.menu.is_none() {
|
||||
level.menu = Some(find_menu_root(ui).id);
|
||||
level.sizing = ui.is_sizing_pass();
|
||||
}
|
||||
level.rows.push(Row { id: r.id, enabled: r.enabled(), submenu });
|
||||
if r.gained_focus() {
|
||||
r.scroll_to_me(None);
|
||||
}
|
||||
let ctx = ui.ctx();
|
||||
let keyboard = active(ctx) && ctx.memory(|m| m.focused()).is_some();
|
||||
if keyboard && r.enabled() && !r.has_focus() && r.hovered() && ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO) {
|
||||
r.request_focus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Submenu entry `button` at `depth` showed its submenu this frame (drawn at `depth + 1`).
|
||||
pub fn opened(&mut self, depth: usize, button: &Response) {
|
||||
if let Some(level) = self.levels.get_mut(depth + 1) {
|
||||
level.owner = Some(button.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// The keyboard highlight: (depth, index) of the focused entry.
|
||||
fn focused(&self, focus: Option<Id>) -> Option<(usize, usize)> {
|
||||
let f = focus?;
|
||||
self.levels.iter().enumerate().rev().find_map(|(d, l)| l.rows.iter().position(|r| r.id == f).map(|i| (d, i)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the menu bar had the keyboard last frame (a menu open or the bar focused).
|
||||
pub fn active(ctx: &Context) -> bool {
|
||||
ctx.data(|d| d.get_temp(active_id())).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Alt pressed and released with nothing else in between (no key, click or wheel), off macOS.
|
||||
fn alt_tapped(ctx: &Context) -> bool {
|
||||
if cfg!(target_os = "macos") {
|
||||
return false;
|
||||
}
|
||||
let mut armed: bool = ctx.data(|d| d.get_temp(alt_id())).unwrap_or(false);
|
||||
let mut tapped = false;
|
||||
ctx.input(|i| {
|
||||
for e in &i.events {
|
||||
match e {
|
||||
Event::ModifiersChanged(m) if *m == Modifiers::ALT => armed = true,
|
||||
Event::ModifiersChanged(m) => {
|
||||
tapped |= armed && *m == Modifiers::NONE;
|
||||
armed = false;
|
||||
}
|
||||
Event::PointerMoved(_) | Event::MouseMoved(_) | Event::Zoom(_) => {}
|
||||
_ => armed = false,
|
||||
}
|
||||
}
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp(alt_id(), armed));
|
||||
tapped
|
||||
}
|
||||
|
||||
/// The next enabled entry of `rows` after `from` (`step` 1) or before it (-1), wrapping around;
|
||||
/// from none: the first (or last) one.
|
||||
fn step(rows: &[Row], from: Option<usize>, step: isize) -> Option<Id> {
|
||||
let n = rows.len() as isize;
|
||||
let start = from.map_or(if step > 0 { -1 } else { n }, |i| i as isize);
|
||||
(1..=n).map(|k| (start + step * k).rem_euclid(n.max(1))).filter_map(|i| rows.get(i as usize)).find(|r| r.enabled).map(|r| r.id)
|
||||
}
|
||||
|
||||
fn focus(ctx: &Context, id: Id) {
|
||||
ctx.memory_mut(|m| m.request_focus(id));
|
||||
}
|
||||
|
||||
/// The bar's menu `i`, wrapping around.
|
||||
fn nth_top(nav: &Nav, i: isize) -> Option<(Id, Id)> {
|
||||
nav.tops.get(i.rem_euclid((nav.tops.len() as isize).max(1)) as usize).copied()
|
||||
}
|
||||
|
||||
/// Move the bar's keyboard focus to its menu `i` (wrapping).
|
||||
fn focus_top(ctx: &Context, nav: &Nav, i: isize) {
|
||||
if let Some((button, _)) = nth_top(nav, i) {
|
||||
focus(ctx, button);
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the top-level menu `i` (wrapping) with its first entry highlighted once drawn.
|
||||
fn open_top(ctx: &Context, nav: &Nav, i: isize) {
|
||||
let Some((button, popup)) = nth_top(nav, i) else { return };
|
||||
Popup::open_id(ctx, popup);
|
||||
focus(ctx, button);
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(pending_id(), FirstEntry(0));
|
||||
d.remove::<KeyOpened>(opened_id());
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the submenu of entry `row` in the menu at `depth`, its first entry highlighted once drawn.
|
||||
fn open_submenu(ctx: &Context, nav: &Nav, depth: usize, row: Id) {
|
||||
let Some(menu) = nav.levels.get(depth).and_then(|l| l.menu) else { return };
|
||||
let sub = SubMenu::id_from_widget_id(row);
|
||||
show_submenu(ctx, menu, sub);
|
||||
let pointer = ctx.pointer_hover_pos();
|
||||
ctx.data_mut(|d| {
|
||||
let mut o: KeyOpened = d.get_temp(opened_id()).unwrap_or_default();
|
||||
o.submenus.retain(|(m, _)| *m != menu);
|
||||
o.submenus.push((menu, sub));
|
||||
o.pointer = pointer;
|
||||
d.insert_temp(opened_id(), o);
|
||||
d.insert_temp(pending_id(), FirstEntry(depth + 1));
|
||||
});
|
||||
}
|
||||
|
||||
/// Handle this frame's keys once the bar and its open menus are drawn (`nav`).
|
||||
pub fn handle(ctx: &Context, nav: &Nav) {
|
||||
let was_active = active(ctx);
|
||||
let open = nav.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p));
|
||||
let focus_id = ctx.memory(|m| m.focused());
|
||||
let bar = focus_id.and_then(|f| nav.tops.iter().position(|(b, _)| *b == f));
|
||||
if alt_tapped(ctx) {
|
||||
if open.is_some() || bar.is_some() {
|
||||
Popup::close_all(ctx);
|
||||
if let Some(f) = focus_id {
|
||||
ctx.memory_mut(|m| m.surrender_focus(f));
|
||||
}
|
||||
} else if let Some((first, _)) = nav.tops.first() {
|
||||
focus(ctx, *first);
|
||||
}
|
||||
ctx.data_mut(|d| d.insert_temp(active_id(), true));
|
||||
strip_keys(ctx);
|
||||
return;
|
||||
}
|
||||
let now_active = open.is_some() || bar.is_some();
|
||||
ctx.data_mut(|d| d.insert_temp(active_id(), now_active));
|
||||
if !now_active {
|
||||
ctx.data_mut(|d| {
|
||||
d.remove::<FirstEntry>(pending_id());
|
||||
d.remove::<KeyOpened>(opened_id());
|
||||
});
|
||||
if was_active {
|
||||
// (The key that closed the menus, e.g. Enter on an entry, stays with them.)
|
||||
strip_keys(ctx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
keep_key_opened(ctx);
|
||||
// A menu opened from the keyboard and now shown: highlight its first entry (if it has an
|
||||
// enabled one).
|
||||
if let Some(FirstEntry(depth)) = ctx.data(|d| d.get_temp(pending_id()))
|
||||
&& let Some(level) = nav.levels.get(depth).filter(|l| !l.sizing)
|
||||
{
|
||||
if let Some(id) = step(&level.rows, None, 1) {
|
||||
focus(ctx, id);
|
||||
}
|
||||
ctx.data_mut(|d| d.remove::<FirstEntry>(pending_id()));
|
||||
}
|
||||
let pressed =
|
||||
|k: Key| ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key, pressed: true, modifiers, .. } if *key == k && !modifiers.any())));
|
||||
let cur = nav.focused(focus_id);
|
||||
let row = cur.and_then(|(d, i)| nav.levels.get(d)?.rows.get(i).copied());
|
||||
for (key, dir) in [(Key::ArrowDown, 1), (Key::ArrowUp, -1)] {
|
||||
if !pressed(key) {
|
||||
continue;
|
||||
}
|
||||
match (open, cur) {
|
||||
(Some(_), Some((d, i))) => {
|
||||
if let Some(id) = nav.levels.get(d).and_then(|l| step(&l.rows, Some(i), dir)) {
|
||||
focus(ctx, id);
|
||||
}
|
||||
}
|
||||
// Opened with the pointer: the deepest open menu's first (last) entry.
|
||||
(Some(_), None) => {
|
||||
if let Some(id) = nav.levels.last().and_then(|l| step(&l.rows, None, dir)) {
|
||||
focus(ctx, id);
|
||||
}
|
||||
}
|
||||
(None, _) if dir > 0 => open_top(ctx, nav, bar.unwrap_or(0) as isize),
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
let top = open.or(bar).unwrap_or(0) as isize;
|
||||
if pressed(Key::ArrowRight) {
|
||||
match (cur, row) {
|
||||
(Some((d, _)), Some(r)) if r.submenu => open_submenu(ctx, nav, d, r.id),
|
||||
_ if open.is_some() => open_top(ctx, nav, top + 1),
|
||||
_ => focus_top(ctx, nav, top + 1),
|
||||
}
|
||||
}
|
||||
if pressed(Key::ArrowLeft) {
|
||||
match cur {
|
||||
Some((d, _)) if d > 0 => {
|
||||
let (menu, owner) = (nav.levels.get(d - 1).and_then(|l| l.menu), nav.levels.get(d).and_then(|l| l.owner));
|
||||
if let Some(menu) = menu {
|
||||
MenuState::from_id(ctx, menu, |s| s.open_item = None);
|
||||
ctx.data_mut(|data| {
|
||||
if let Some(mut o) = data.get_temp::<KeyOpened>(opened_id()) {
|
||||
o.submenus.retain(|(m, _)| *m != menu);
|
||||
data.insert_temp(opened_id(), o);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(owner) = owner {
|
||||
focus(ctx, owner);
|
||||
}
|
||||
}
|
||||
_ if open.is_some() => open_top(ctx, nav, top - 1),
|
||||
_ => focus_top(ctx, nav, top - 1),
|
||||
}
|
||||
}
|
||||
if pressed(Key::Enter) || pressed(Key::Space) {
|
||||
match (cur, row) {
|
||||
// egui toggles a submenu on Enter: it opens (again), highlighting its first entry.
|
||||
(Some((d, _)), Some(r)) if r.submenu => open_submenu(ctx, nav, d, r.id),
|
||||
// A top-level menu that the press opened.
|
||||
(None, _) if bar.is_some() && open.is_some() => ctx.data_mut(|d| {
|
||||
d.insert_temp(pending_id(), FirstEntry(0));
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The arrows move the highlight here, not egui's spatial focus search.
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
strip_keys(ctx);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Keep the submenus opened from the keyboard open while the pointer hasn't moved.
|
||||
fn keep_key_opened(ctx: &Context) {
|
||||
let Some(o) = ctx.data(|d| d.get_temp::<KeyOpened>(opened_id())) else { return };
|
||||
if ctx.pointer_hover_pos() != o.pointer {
|
||||
ctx.data_mut(|d| d.remove::<KeyOpened>(opened_id()));
|
||||
return;
|
||||
}
|
||||
for (menu, sub) in o.submenus {
|
||||
show_submenu(ctx, menu, sub);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `sub` the open submenu of `menu`. (egui forgets an open submenu that wasn't shown last
|
||||
/// frame, so it counts as shown now.)
|
||||
fn show_submenu(ctx: &Context, menu: Id, sub: Id) {
|
||||
MenuState::mark_shown(ctx, sub);
|
||||
MenuState::from_id(ctx, menu, |s| s.open_item = Some(sub));
|
||||
}
|
||||
|
||||
/// The rest of the frame (shortcuts, panels) doesn't see this frame's keys.
|
||||
fn strip_keys(ctx: &Context) {
|
||||
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Key { .. } | Event::Text(_))));
|
||||
}
|
||||
|
|
@ -1480,7 +1480,9 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() {
|
||||
ctx.data_mut(|d| d.remove::<bool>(space_tap_id()));
|
||||
}
|
||||
if ctx.egui_wants_keyboard_input() {
|
||||
// While a menu or popup list is open, keys go to it, not to the app (Space must not start a
|
||||
// preview behind the menu, #279).
|
||||
if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) {
|
||||
return;
|
||||
}
|
||||
// Text editing in the viewer takes the clipboard events itself.
|
||||
|
|
@ -1510,6 +1512,16 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
})
|
||||
.collect()
|
||||
});
|
||||
// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it
|
||||
// comes as text.
|
||||
if clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) {
|
||||
let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone()));
|
||||
if let Some((id, params)) = bound
|
||||
&& let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params })
|
||||
{
|
||||
app.ui.status = e;
|
||||
}
|
||||
}
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1581,6 +1593,7 @@ pub fn handle_shortcuts(app: &mut EffectcraftApp, ctx: &egui::Context) {
|
|||
pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let mut nav = crate::menu_keys::Nav::default();
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.add_space(6.0);
|
||||
egui::MenuBar::new().ui(ui, |ui| {
|
||||
|
|
@ -1588,13 +1601,15 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
|||
if let MenuNode::Submenu { label, children } = node {
|
||||
let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| {
|
||||
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
|
||||
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked));
|
||||
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
|
||||
});
|
||||
nav.top(&r.response);
|
||||
app.auto.add(&format!("menu.{label}"), r.response.rect, label);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
crate::menu_keys::handle(&ctx, &nav);
|
||||
if let Some((id, params)) = clicked
|
||||
&& let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params })
|
||||
{
|
||||
|
|
@ -1609,12 +1624,20 @@ pub(crate) fn menu_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, name: &
|
|||
if let Some(MenuNode::Submenu { children, .. }) =
|
||||
effectcraft_engine::menus::menu_bar().iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == name))
|
||||
{
|
||||
menu_nodes(app, ui, children, &mut clicked);
|
||||
menu_nodes(app, ui, children, &mut clicked, &mut crate::menu_keys::Nav::default(), 0);
|
||||
}
|
||||
clicked.map(|(id, params)| (id, if params.is_null() { json!({}) } else { params }))
|
||||
}
|
||||
|
||||
fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], clicked: &mut Option<(String, Value)>) {
|
||||
/// The entries `nodes` of a menu `depth` submenus deep, registered with `nav` for the keyboard.
|
||||
fn menu_nodes(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
nodes: &[MenuNode],
|
||||
clicked: &mut Option<(String, Value)>,
|
||||
nav: &mut crate::menu_keys::Nav,
|
||||
depth: usize,
|
||||
) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
MenuNode::Separator => {
|
||||
|
|
@ -1644,6 +1667,7 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
|
|||
None => r,
|
||||
};
|
||||
app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label);
|
||||
nav.entry(ui, depth, &r, false);
|
||||
if r.clicked() {
|
||||
*clicked = Some((e.command.clone(), e.params.clone()));
|
||||
ui.close();
|
||||
|
|
@ -1653,13 +1677,19 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c
|
|||
MenuNode::Submenu { label, children } => {
|
||||
let ws = app.ui.workspace.clone();
|
||||
let shown = crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[])));
|
||||
ui.menu_button((gutter(false), shown.as_str()), |ui| {
|
||||
let r = ui.menu_button((gutter(false), shown.as_str()), |ui| {
|
||||
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
|
||||
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked));
|
||||
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
|
||||
});
|
||||
nav.entry(ui, depth, &r.response, true);
|
||||
if r.inner.is_some() {
|
||||
nav.opened(depth, &r.response);
|
||||
}
|
||||
}
|
||||
MenuNode::Item(e) => {
|
||||
if menu_entry(app, ui, e) {
|
||||
let r = menu_entry(app, ui, e);
|
||||
nav.entry(ui, depth, &r, false);
|
||||
if r.clicked() {
|
||||
*clicked = Some((e.command.clone(), e.params.clone()));
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -1681,7 +1711,7 @@ pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&s
|
|||
found = Some(n);
|
||||
}
|
||||
if let Some(n) = found {
|
||||
menu_nodes(app, ui, std::slice::from_ref(n), clicked);
|
||||
menu_nodes(app, ui, std::slice::from_ref(n), clicked, &mut crate::menu_keys::Nav::default(), 0);
|
||||
}
|
||||
if let Some((_, p)) = clicked.as_mut()
|
||||
&& p.is_null()
|
||||
|
|
@ -1693,7 +1723,7 @@ pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&s
|
|||
/// The menu bar's entry for `command` in a panel's context menu (see [`submenu_at`]).
|
||||
pub(crate) fn entry_for(app: &EffectcraftApp, ui: &mut egui::Ui, command: &str, clicked: &mut Option<(String, Value)>) {
|
||||
if let Some((_, e)) = effectcraft_engine::menus::entries().into_iter().find(|(_, e)| e.command == command)
|
||||
&& menu_entry(app, ui, e)
|
||||
&& menu_entry(app, ui, e).clicked()
|
||||
{
|
||||
*clicked = Some((e.command.clone(), e.params_or_empty()));
|
||||
ui.close();
|
||||
|
|
@ -1711,7 +1741,7 @@ fn gutter(checked: bool) -> egui::Atom<'static> {
|
|||
(if checked { "✔" } else { "" }).atom_size(egui::vec2(14.0, 14.0))
|
||||
}
|
||||
|
||||
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
|
||||
fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::Response {
|
||||
let (label, check, enabled) = (entry_label(app, e), gutter(entry_checked(app, e) == Some(true)), entry_enabled(app, e));
|
||||
// Edit ▸ Label's entries show the label's colour before its name.
|
||||
if e.command == "edit.label"
|
||||
|
|
@ -1719,13 +1749,13 @@ fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> bool {
|
|||
{
|
||||
let out = ui.add_enabled_ui(enabled, |ui| egui::Button::new((check, crate::widgets::label_swatch(), label)).atom_ui(ui)).inner;
|
||||
crate::widgets::paint_label_swatch(ui, &out, l, &app.tokens);
|
||||
return out.response.clicked();
|
||||
return out.response;
|
||||
}
|
||||
let mut b = egui::Button::new((check, label));
|
||||
if let Some(s) = entry_shortcut(app, e) {
|
||||
b = b.shortcut_text(shortcut_text(&s));
|
||||
}
|
||||
ui.add_enabled(enabled, b).clicked()
|
||||
ui.add_enabled(enabled, b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -4,15 +4,17 @@
|
|||
//! Gestures, as in After Effects: drag moves a marker (snapping to the current time, the work
|
||||
//! area and other markers), Alt-drag sets its duration, Cmd-click (Ctrl-click elsewhere)
|
||||
//! deletes it, double-click opens the Marker dialog, right-click offers Settings / Convert /
|
||||
//! Delete. Protected regions (Responsive Design) shade the timeline. Every change is a
|
||||
//! `markers.*` engine command, so it is undoable and agent-drivable.
|
||||
//! Delete. Protected regions (Responsive Design) shade the timeline. A marker dragged out of the
|
||||
//! composition marker bin, at the right end of the time ruler, adds a composition marker where it
|
||||
//! is dropped. Every change is an engine command, so it is undoable and agent-drivable.
|
||||
|
||||
use effectcraft_engine::project::{Comp, Layer, Marker};
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2};
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::timeline::TMap;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
|
|
@ -253,6 +255,17 @@ fn snap(app: &EffectcraftApp, comp: &Comp, tm: TMap, t: f64, skip: MarkerRef) ->
|
|||
t
|
||||
}
|
||||
|
||||
/// A marker's colour: its label's, pale yellow without one.
|
||||
fn marker_color(t: &Tokens, label: effectcraft_engine::color::Label) -> Color32 {
|
||||
if label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(label) }
|
||||
}
|
||||
|
||||
/// The marker glyph at `x` from `y0`, `h` points tall: a tab pointing down at the time.
|
||||
fn marker_shape(x: f32, y0: f32, h: f32) -> Vec<Pos2> {
|
||||
let ym = y0 + h * 0.6;
|
||||
vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)]
|
||||
}
|
||||
|
||||
/// Draw one marker (triangle + duration bar + comment) at `x` in a strip `[y0, y1]` and handle
|
||||
/// its gestures. `comp_t` is the marker's comp time.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
|
|
@ -260,9 +273,8 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
|
|||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
let x = tm.x(comp_t);
|
||||
let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) };
|
||||
let col = marker_color(&t, m.label);
|
||||
let h = (y1 - y0).min(10.0);
|
||||
let ym = y0 + h * 0.6;
|
||||
if m.duration > Tick(0) {
|
||||
let xe = tm.x(comp_t + m.duration.seconds());
|
||||
let bar = Rect::from_min_max(pos2(x, y0 + 1.0), pos2(xe, y0 + h - 1.0));
|
||||
|
|
@ -276,7 +288,7 @@ fn marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp:
|
|||
}
|
||||
p.line_segment([pos2(xe, y0), pos2(xe, y0 + h)], Stroke::new(1.0, col));
|
||||
}
|
||||
p.add(egui::Shape::convex_polygon(vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)], col, Stroke::NONE));
|
||||
p.add(egui::Shape::convex_polygon(marker_shape(x, y0, h), col, Stroke::NONE));
|
||||
if !m.comment.is_empty() {
|
||||
p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text);
|
||||
}
|
||||
|
|
@ -365,6 +377,37 @@ pub(crate) fn comp_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &C
|
|||
}
|
||||
}
|
||||
|
||||
/// The composition marker bin at the right end of the time ruler (`bin`), as in After Effects
|
||||
/// (#275): drag a marker out of it and it follows the pointer along the marker strip `strip`
|
||||
/// (snapping like a dragged marker); dropping it there adds a composition marker at that time,
|
||||
/// one undo step. Dropped back on the bin or outside the time graph's width, nothing is added.
|
||||
pub(crate) fn marker_bin(app: &mut EffectcraftApp, ui: &mut egui::Ui, comp: &Comp, tm: TMap, bin: Rect, strip: Rect) {
|
||||
let t = app.tokens;
|
||||
let resp = ui.interact(bin, egui::Id::new("marker-bin"), Sense::drag()).on_hover_text("Composition marker bin: drag a marker to the time ruler");
|
||||
app.auto.add("timeline.markerBin", bin, "Composition marker bin");
|
||||
let hot = resp.hovered() || resp.dragged();
|
||||
if hot {
|
||||
ui.ctx().set_cursor_icon(if resp.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
icons::paint(ui.painter(), bin, Icon::MarkerBin, if hot { t.icon_active } else { t.icon });
|
||||
let at = resp.interact_pointer_pos().filter(|p| p.x >= strip.min.x && p.x < strip.max.x).map(|p| {
|
||||
let snapped = snap(app, comp, tm, tm.t(p.x), MarkerRef { layer: None, index: usize::MAX });
|
||||
snapped.min(comp.duration.seconds())
|
||||
});
|
||||
let Some(at) = at else { return };
|
||||
if resp.dragged() {
|
||||
let h = strip.height().min(10.0);
|
||||
let col = marker_color(&t, effectcraft_engine::color::Label::None);
|
||||
let p = ui.painter().with_clip_rect(strip);
|
||||
p.add(egui::Shape::convex_polygon(marker_shape(tm.x(at), strip.min.y, h), col, Stroke::NONE));
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
&& let Err(e) = app.session.execute("comp.addMarker", json!({"time": at}))
|
||||
{
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer markers on a layer's row `r`; on a precomp layer, its comp's markers too (read-only,
|
||||
/// outlined: hover names them, a double-click opens the nested comp at the marker).
|
||||
pub(crate) fn layer_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, clip: Rect, comp: &Comp, layer: &Layer, tm: TMap, r: Rect) {
|
||||
|
|
@ -399,10 +442,8 @@ fn nested_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
|||
let (y0, h) = (r.min.y + 3.0, (r.height() - 6.0).min(10.0));
|
||||
for (ct, i, m) in list {
|
||||
let x = tm.x(ct.seconds());
|
||||
let col = if m.label == effectcraft_engine::color::Label::None { Color32::from_rgb(0xd8, 0xd8, 0x60) } else { t.label(m.label) }.gamma_multiply(0.75);
|
||||
let ym = y0 + h * 0.6;
|
||||
let pts = vec![pos2(x - 4.0, y0), pos2(x + 4.0, y0), pos2(x + 4.0, ym), pos2(x, y0 + h), pos2(x - 4.0, ym)];
|
||||
p.add(egui::Shape::closed_line(pts, Stroke::new(1.2, col)));
|
||||
let col = marker_color(&t, m.label).gamma_multiply(0.75);
|
||||
p.add(egui::Shape::closed_line(marker_shape(x, y0, h), Stroke::new(1.2, col)));
|
||||
if !m.comment.is_empty() {
|
||||
p.text(pos2(x + 7.0, y0 + h / 2.0), Align2::LEFT_CENTER, m.comment.lines().next().unwrap_or_default(), Tokens::ui(10.0), t.text_dim);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,7 +141,8 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
|||
r
|
||||
}
|
||||
Kind::Choice(opts) => {
|
||||
let curv = v.as_str().unwrap_or("").to_string();
|
||||
// (A number with choices, UI Scale, shows as its text.)
|
||||
let curv = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
let shown = opts.iter().find(|o| o.1 == curv).map(|o| o.0).unwrap_or(curv.as_str()).to_string();
|
||||
egui::ComboBox::from_id_salt(("settings", key))
|
||||
.selected_text(shown)
|
||||
|
|
@ -459,7 +460,9 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
|||
});
|
||||
ui.separator();
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(590.0);
|
||||
// The rest of the dialog's width: a fixed width ran past it, so the whole
|
||||
// dialog scrolled sideways a little (#284).
|
||||
ui.set_width(ui.available_width());
|
||||
let p = &all[idx];
|
||||
ui.label(RichText::new(p.title).font(Tokens::semibold(14.0)).color(t.tab_text_active));
|
||||
ui.add_space(6.0);
|
||||
|
|
|
|||
|
|
@ -394,6 +394,9 @@ fn tl_map_id() -> egui::Id {
|
|||
egui::Id::new("timeline-map")
|
||||
}
|
||||
|
||||
/// The column right of the time graph: the composition marker bin over the layers' scroll bar.
|
||||
const GUTTER_W: f32 = 18.0;
|
||||
|
||||
/// The most a time ruler zooms in (pixels per second).
|
||||
const MAX_PPS: f64 = 4000.0;
|
||||
|
||||
|
|
@ -1128,7 +1131,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let oscroll = app.ui.timeline.outline_scroll;
|
||||
let cw = cols(rect.min.x - oscroll, left_w + overflow, vis);
|
||||
let graph_x0 = rect.min.x + left_w + 1.0;
|
||||
let graph_x1 = rect.max.x - 10.0;
|
||||
let graph_x1 = rect.max.x - GUTTER_W;
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
|
||||
let fit_pps = fit_pps(graph_x1 - graph_x0, &comp);
|
||||
let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
|
||||
|
|
@ -1140,7 +1143,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let top = rect.min.y;
|
||||
let rows_top = top + header_h + colhdr_h;
|
||||
let rows_rect = Rect::from_min_max(pos2(rect.min.x, rows_top), pos2(rect.max.x, rect.max.y - footer_h));
|
||||
let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), pos2(graph_x1 + 10.0, rect.max.y - footer_h));
|
||||
let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), pos2(rect.max.x, rect.max.y - footer_h));
|
||||
p.rect_filled(Rect::from_min_max(pos2(graph_x0, top), rect.max), 0.0, t.tl_bg);
|
||||
p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg));
|
||||
|
||||
|
|
@ -1208,6 +1211,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
|
||||
// ---- ruler (right).
|
||||
let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
|
||||
// The composition marker bin, at the right end of the ruler above the layers' scroll bar.
|
||||
let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
|
||||
let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 14.0));
|
||||
// Time navigator: the visible span as a grey bar with blue end handles (as in After
|
||||
// Effects), over a dark track.
|
||||
|
|
@ -1366,7 +1371,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let secs_per_label = label_frames as f64 * fd;
|
||||
let first = (tm.start / secs_per_label).floor() * secs_per_label;
|
||||
let mut s = first;
|
||||
let pr = p.with_clip_rect(Rect::from_min_max(pos2(graph_x0, ruler.min.y), ruler.max));
|
||||
let pr = p.with_clip_rect(Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(bin.min.x, ruler.max.y)));
|
||||
while s <= tm.t(graph_x1) + secs_per_label {
|
||||
let x = tm.x(s);
|
||||
pr.line_segment([pos2(x, ruler.max.y - 10.0), pos2(x, ruler.max.y)], Stroke::new(1.0, t.tl_ruler_tick));
|
||||
|
|
@ -1385,7 +1390,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
// Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down
|
||||
// (After Effects), then follows it.
|
||||
let rresp = ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), ruler.max), egui::Id::new("tl-ruler"), Sense::click_and_drag());
|
||||
let rresp =
|
||||
ui.interact(Rect::from_min_max(pos2(graph_x0, ruler.min.y + 12.0), pos2(bin.min.x, ruler.max.y)), egui::Id::new("tl-ruler"), Sense::click_and_drag());
|
||||
if ((rresp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_down())) || rresp.clicked())
|
||||
&& let Some(pt) = rresp.interact_pointer_pos()
|
||||
{
|
||||
|
|
@ -2721,9 +2727,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
}
|
||||
|
||||
// Composition markers in the ruler; protected regions shade the layer rows.
|
||||
let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(graph_x1 + 10.0, ruler.max.y));
|
||||
let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(bin.min.x, ruler.max.y));
|
||||
let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
|
||||
super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
|
||||
super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip);
|
||||
|
||||
// Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when
|
||||
// zoomed in (registered last, so it takes the drag from the bars, keys and markers there).
|
||||
|
|
|
|||
|
|
@ -459,6 +459,8 @@ pub fn popup_list<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r
|
|||
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui));
|
||||
});
|
||||
});
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.ctx().data_mut(|d| d.insert_temp(popup_list_pass_id(), pass));
|
||||
let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p));
|
||||
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
|
||||
|
|
@ -466,6 +468,18 @@ pub fn popup_list<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r
|
|||
chosen
|
||||
}
|
||||
|
||||
/// The pass a [`popup_list`] was last drawn in.
|
||||
fn popup_list_pass_id() -> egui::Id {
|
||||
egui::Id::new("popup-list-pass")
|
||||
}
|
||||
|
||||
/// Whether a menu, context menu or popup list is open, or the menu bar has the keyboard (as of
|
||||
/// the last frame): the app's keyboard shortcuts wait until it closes (#279).
|
||||
pub fn any_menu_open(ctx: &egui::Context) -> bool {
|
||||
let list_pass: Option<u64> = ctx.data(|d| d.get_temp(popup_list_pass_id()));
|
||||
egui::Popup::is_any_open(ctx) || crate::menu_keys::active(ctx) || list_pass.is_some_and(|p| p + 1 >= ctx.cumulative_pass_nr())
|
||||
}
|
||||
|
||||
/// A menu's (or submenu's) entries, scrolling with a scroll bar when they are taller than the
|
||||
/// window instead of running off it (Edit on a short screen, #269; Blending Mode). Others show
|
||||
/// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
//! The menu bar from the keyboard, and the app's shortcuts waiting while a menu is open (#279)
|
||||
//! (egui_kittest, UI logic only).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Key, Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> Harness<'static, EffectcraftApp> {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.openDemoProject", json!({})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}"));
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
h.input_mut().events.push(Event::PointerMoved(p));
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// Press and release `key` (one frame each), then let the UI settle.
|
||||
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// The label of the widget with keyboard focus (the highlighted menu entry).
|
||||
fn focused(h: &Harness<'_, EffectcraftApp>) -> String {
|
||||
h.query_by(|n| n.is_focused()).and_then(|n| n.accesskit_node().label()).unwrap_or_default().trim().to_string()
|
||||
}
|
||||
|
||||
fn shown(h: &Harness<'_, EffectcraftApp>, label: &str) -> bool {
|
||||
h.query_by_label_contains(label).is_some()
|
||||
}
|
||||
|
||||
/// Space (or any shortcut) while a menu is open goes to the menu, not the app: it started the
|
||||
/// preview behind the open menu.
|
||||
#[test]
|
||||
fn shortcuts_wait_while_a_menu_is_open() {
|
||||
let mut h = harness();
|
||||
let edit = rect(&h, "menu.Edit");
|
||||
click_at(&mut h, edit.center());
|
||||
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open");
|
||||
key(&mut h, Key::Space);
|
||||
assert!(!h.state().playback.playing, "Space didn't start the preview");
|
||||
// With the menu closed, Space previews again.
|
||||
key(&mut h, Key::Escape);
|
||||
assert!(!shown(&h, "Keyboard Shortcuts"), "Escape closed the menu");
|
||||
key(&mut h, Key::Space);
|
||||
assert!(h.state().playback.playing, "Space previews with the menus closed");
|
||||
}
|
||||
|
||||
/// Alt focuses the menu bar; Down opens the menu, Up / Down move the highlight, Right opens a
|
||||
/// submenu and Left closes it, Right on an entry opens the next menu, Escape closes them.
|
||||
#[test]
|
||||
fn arrow_keys_drive_the_menu_bar() {
|
||||
if cfg!(target_os = "macos") {
|
||||
// (macOS menus are the system's.)
|
||||
return;
|
||||
}
|
||||
let mut h = harness();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::ALT));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(2);
|
||||
assert_eq!(focused(&h), "File", "Alt focused the menu bar");
|
||||
key(&mut h, Key::ArrowDown);
|
||||
assert!(shown(&h, "Open Project"), "Down opened File");
|
||||
assert!(focused(&h).starts_with("New"), "its first entry is highlighted: {:?}", focused(&h));
|
||||
key(&mut h, Key::ArrowRight);
|
||||
assert!(focused(&h).contains("New Project"), "Right opened the New submenu: {:?}", focused(&h));
|
||||
key(&mut h, Key::ArrowDown);
|
||||
assert!(focused(&h).contains("New Project from Template"), "Down moved down the submenu: {:?}", focused(&h));
|
||||
key(&mut h, Key::ArrowLeft);
|
||||
assert!(!shown(&h, "New Project from Template"), "Left closed the submenu");
|
||||
assert!(focused(&h).starts_with("New"), "and went back to its entry: {:?}", focused(&h));
|
||||
key(&mut h, Key::ArrowDown);
|
||||
assert!(focused(&h).contains("Open Project"), "{:?}", focused(&h));
|
||||
key(&mut h, Key::ArrowRight);
|
||||
assert!(!shown(&h, "Open Project"), "Right on an entry left File");
|
||||
assert!(shown(&h, "Keyboard Shortcuts"), "for Edit");
|
||||
key(&mut h, Key::ArrowLeft);
|
||||
assert!(shown(&h, "Open Project"), "Left went back to File");
|
||||
key(&mut h, Key::Escape);
|
||||
assert!(!shown(&h, "Open Project"), "Escape closed the menus");
|
||||
assert!(!h.state().playback.playing, "no key reached the app");
|
||||
}
|
||||
|
||||
/// Up in a menu opened with the pointer highlights its last entry (wrapping around); Enter on a
|
||||
/// submenu entry opens it with its first entry highlighted, and Enter chooses an entry.
|
||||
#[test]
|
||||
fn enter_chooses_the_highlighted_entry() {
|
||||
let mut h = harness();
|
||||
let edit = rect(&h, "menu.Edit");
|
||||
click_at(&mut h, edit.center());
|
||||
key(&mut h, Key::ArrowUp);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert!(focused(&h).starts_with("Keyboard Shortcuts"), "Up went to the last entry: {:?}", focused(&h));
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "Enter chose it");
|
||||
} else {
|
||||
assert!(focused(&h).starts_with("Preferences"), "Up went to the last entry: {:?}", focused(&h));
|
||||
key(&mut h, Key::Enter);
|
||||
assert!(focused(&h).starts_with("General"), "Enter opened the submenu: {:?}", focused(&h));
|
||||
key(&mut h, Key::Enter);
|
||||
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Settings), "Enter chose General...");
|
||||
}
|
||||
assert!(!shown(&h, "Quick Apply"), "and closed the menu");
|
||||
}
|
||||
|
|
@ -190,6 +190,48 @@ fn leaving_the_learn_workspace_closes_its_tutorials() {
|
|||
}
|
||||
}
|
||||
|
||||
/// #284: Settings ▸ Appearance ▸ UI Scale sizes the whole interface as soon as it is chosen,
|
||||
/// Cancel puts it back, OK keeps it; Ctrl+0 (Window ▸ Project) doesn't reset it.
|
||||
#[test]
|
||||
fn ui_scale_setting_sizes_the_interface() {
|
||||
let mut h = harness();
|
||||
let ctx = h.ctx.clone();
|
||||
let choose = |h: &mut Harness<'_, EffectcraftApp>, label: &str| {
|
||||
let ctx = h.ctx.clone();
|
||||
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page": "appearance"})).unwrap();
|
||||
h.run_steps(3);
|
||||
click(h, "settings.appearance.uiScale");
|
||||
let option = h.query_by_label(label).unwrap_or_else(|| panic!("the {label} choice")).rect().center();
|
||||
click_at(h, option);
|
||||
h.run_steps(2);
|
||||
};
|
||||
assert_eq!(ctx.zoom_factor(), 1.0);
|
||||
choose(&mut h, "150%");
|
||||
assert_eq!(h.state().session.prefs.appearance.ui_scale, 150);
|
||||
assert_eq!(ctx.zoom_factor(), 1.5, "live");
|
||||
click(&mut h, "settings.cancel");
|
||||
h.run_steps(2);
|
||||
assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (100, 1.0), "Cancel put it back");
|
||||
choose(&mut h, "125%");
|
||||
click(&mut h, "settings.ok");
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!((h.state().session.prefs.appearance.ui_scale, ctx.zoom_factor()), (125, 1.25), "OK kept it");
|
||||
// Ctrl+0 opens the Project panel; egui's own Ctrl+0 zoom reset stays out of it.
|
||||
for pressed in [true, false] {
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::Num0, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND });
|
||||
h.step();
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(ctx.zoom_factor(), 1.25);
|
||||
// A saved scale applies from the first frame.
|
||||
let mut s = Session::default();
|
||||
s.prefs.set("appearance.uiScale", json!(175)).unwrap();
|
||||
let mut h2 = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h2.run_steps(3);
|
||||
assert_eq!(h2.ctx.zoom_factor(), 1.75);
|
||||
}
|
||||
|
||||
/// Headless look at the panels (wgpu offscreen; needs a GPU adapter). Run with
|
||||
/// `PANELS_SNAPSHOT=/abs/dir cargo test -p effectcraft-ui-egui --test ui_panels -- --ignored`.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -143,3 +143,23 @@ fn effect_values_stay_clear_of_the_keyframe_navigator() {
|
|||
assert!(value.max.x <= nav.min.x, "the value ({value:?}) ends before the navigator ({nav:?})");
|
||||
assert!(value.min.x > rect(&h, &format!("effectControls.prop.{uid}.stopwatch")).max.x, "and after the stopwatch");
|
||||
}
|
||||
|
||||
/// The Settings dialog fits its width: its page column was wider than the dialog, so the whole
|
||||
/// dialog scrolled sideways a little (#284).
|
||||
#[test]
|
||||
fn settings_dialog_does_not_scroll_sideways() {
|
||||
let mut h = harness(1400.0, 900.0);
|
||||
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 (ok, page) = (rect(&h, "settings.ok"), rect(&h, "settings.page.general"));
|
||||
h.input_mut().events.push(Event::PointerMoved(page.center()));
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(-400.0, 0.0),
|
||||
modifiers: Default::default(),
|
||||
phase: egui::TouchPhase::Move,
|
||||
});
|
||||
h.run_steps(20);
|
||||
assert_eq!((rect(&h, "settings.ok"), rect(&h, "settings.page.general")), (ok, page), "nothing moved sideways");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! Zooming the Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp
|
||||
//! shows (#158); the Time Navigator's ends drag to zoom and `;` toggles frame level / the whole
|
||||
//! comp (#159).
|
||||
//! The Timeline's time ruler with real input: Alt+wheel zooms out until the whole comp shows
|
||||
//! (#158); the Time Navigator's ends drag to zoom and `;` toggles frame level / the whole comp
|
||||
//! (#159); markers dragged out of the composition marker bin (#275).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
|
|
@ -89,7 +89,8 @@ fn drag_with(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, modifier
|
|||
fn visible(h: &Harness<'_, EffectcraftApp>) -> (f64, f64) {
|
||||
let e = h.state().auto.find("timeline.ruler").unwrap().clone();
|
||||
let (start, pps) = e.label.split_once(',').map(|(a, b)| (a.parse::<f64>().unwrap(), b.parse::<f64>().unwrap())).unwrap();
|
||||
(start, start + (e.rect[2] - 16.0) as f64 / pps)
|
||||
// (The time graph ends 18 px before the ruler, at the marker bin; it maps time from 6 px in.)
|
||||
(start, start + (e.rect[2] - 24.0) as f64 / pps)
|
||||
}
|
||||
|
||||
/// #159: dragging the navigator's end handles changes that side of the visible span; pulling
|
||||
|
|
@ -271,3 +272,42 @@ fn a_press_in_the_ruler_moves_the_current_time() {
|
|||
h.event(Event::PointerButton { pos: at + vec2(100.0, 0.0), button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// #275: a marker dragged out of the composition marker bin, at the right end of the time ruler,
|
||||
/// adds a composition marker where it is dropped, as one undo step; dragged back onto the bin,
|
||||
/// none. Numpad * (typed `*`) adds one at the current time.
|
||||
#[test]
|
||||
fn dragging_out_of_the_marker_bin_adds_a_comp_marker() {
|
||||
let (mut h, _) = harness();
|
||||
h.state_mut().ui.timeline.pps = Some(200.0);
|
||||
h.state_mut().ui.timeline.start = 10.0;
|
||||
h.run_steps(2);
|
||||
let markers = |h: &Harness<'_, EffectcraftApp>| -> Vec<f64> { h.state().session.active_comp().unwrap().markers.iter().map(|m| m.time.seconds()).collect() };
|
||||
let bin = rect(&h, "timeline.markerBin");
|
||||
let ruler = rect(&h, "timeline.ruler");
|
||||
assert!(ruler.contains_rect(bin) && bin.max.x > ruler.max.x - 20.0, "at the ruler's right end: {bin:?} in {ruler:?}");
|
||||
// Out to the ruler and back onto the bin: nothing.
|
||||
let at = pos2(ruler.min.x + 6.0 + 600.0, ruler.center().y);
|
||||
h.event(Event::PointerMoved(bin.center()));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: bin.center(), button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for p in [at, bin.center()] {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: bin.center(), button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(2);
|
||||
assert!(markers(&h).is_empty(), "dropped on the bin: {:?}", markers(&h));
|
||||
// Dropped 3 s into the visible span (from 10 s at 200 px/s): a marker at 13 s.
|
||||
let undo = h.state().session.history.undo.len();
|
||||
drag(&mut h, bin.center(), at);
|
||||
assert_eq!(markers(&h), vec![13.0]);
|
||||
let history = &h.state().session.history.undo;
|
||||
assert_eq!((history.len(), history.last().map(|u| u.0.as_str())), (undo + 1, Some("Add Marker")), "one undo step");
|
||||
// Numpad * (it arrives as text): Add Marker at the current time.
|
||||
h.state_mut().session.execute("time.set", json!({"time": 20.0})).unwrap();
|
||||
h.event(Event::Text("*".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(markers(&h), vec![13.0, 20.0]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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 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) |
|
||||
| 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) |
|
||||
|
|
|
|||
|
|
@ -56,6 +56,8 @@ Commands (CLI, MCP, control channel):
|
|||
- `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects)
|
||||
- `appearance.theme`: Theme
|
||||
- `appearance.brightness`: Brightness
|
||||
- `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the
|
||||
display's own scaling), applied as soon as it is chosen
|
||||
- `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths
|
||||
- `grids.gridColor`: Color
|
||||
- `grids.gridSpacing`: Gridline Every
|
||||
|
|
|
|||
Loading…
Reference in New Issue