diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 779459fb8..f013e5b79 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -177,12 +177,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Right side: community buttons, workspaces. let mut rx = rect.max.x - 10.0; - let discord = Rect::from_min_max(pos2(rx - 104.0, cy - 13.0), pos2(rx, cy + 13.0)); + // The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the + // text at its left, which left a wide gap on the right (#581). + let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE); + let discord = Rect::from_min_max(pos2(rx - (10.0 + 14.0 + 6.0 + label.size().x + 12.0), cy - 13.0), pos2(rx, cy + 13.0)); let dresp = ui.interact(discord, egui::Id::new("hdr-discord"), Sense::click()); let dc = Color32::from_rgb(0x58, 0x65, 0xf2); p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc }); - icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 16.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); - p.text(pos2(discord.min.x + 28.0, cy), Align2::LEFT_CENTER, tr("Discord"), Tokens::semibold(12.0), Color32::WHITE); + icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); + p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE); app.auto.add("header.discord", discord, "Join the ArtCraft Discord"); if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() { let _ = app.session.execute("help.discord", json!({})); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9283dce27..03c12da7e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -122,6 +122,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.home", "Home", [], None), uic!("markers.dialog", "Marker Settings...", [], None), uic!("window.maximizePanel", "Maximize Panel Under Pointer", [], Some("`")), + uic!("window.maximizeApp", "Maximize or Restore Application Window", [], Some("Cmd+\\")), uic!("window.dockPanel", "Dock Panel", [], None), uic!("window.floatPanel", "Undock Panel", [], None), uic!("window.closePanel", "Close Panel", [], None), @@ -416,6 +417,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V _ => {} } } + // A UI-only command (not in the engine's menus), so `frontend` gets it from here. + if id == "window.maximizeApp" { + return frontend(app, ctx, id, params); + } // Docking: {panel, anchor, zone: center|left|right|top|bottom}, {panel, rect?}, {panel?}. if let Some(op) = id.strip_prefix("window.").filter(|o| matches!(*o, "maximizePanel" | "dockPanel" | "floatPanel" | "closePanel")) { let panel_of = |k: &str| params.get(k).and_then(Value::as_str).map(|s| PanelKind::from_name(s).ok_or(format!("unknown panel `{s}`"))); @@ -909,6 +914,12 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu }; Value::Null } + // Ctrl+\ / Cmd+\, as in After Effects (#336): `{maximized}`, the window's new state. + "window.maximizeApp" => { + let maximized = !ctx.input(|i| i.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(maximized)); + json!({"maximized": maximized}) + } "view.fullScreen" => { let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(!full)); @@ -1866,6 +1877,34 @@ mod tests { } } + /// #336: Ctrl+\ (Cmd+\ on macOS) maximizes the application window, and again restores + /// it, as in After Effects. + #[test] + fn ctrl_backslash_maximizes_or_restores_the_window() { + let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); + let (m, k) = parse_shortcut("Cmd+\\").unwrap(); + assert!(m.command && k == egui::Key::Backslash, "{m:?} {k:?}"); + assert!(bindings(&app.session).iter().any(|b| (b.0, b.1) == (m, k) && b.2 == "window.maximizeApp")); + let ctx = egui::Context::default(); + let run = |app: &mut EffectcraftApp, maximized: bool| { + let input = egui::RawInput { + viewports: std::iter::once((egui::ViewportId::ROOT, egui::ViewportInfo { maximized: Some(maximized), ..Default::default() })).collect(), + ..Default::default() + }; + let mut r = Value::Null; + let mut out = ctx.run_ui(input, |ui| r = invoke(app, ui.ctx(), "window.maximizeApp", json!({})).unwrap()); + out.textures_delta.clear(); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + (r, cmds) + }; + let (r, cmds) = run(&mut app, false); + assert_eq!(r, json!({"maximized": true})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(true)), "{cmds:?}"); + let (r, cmds) = run(&mut app, true); + assert_eq!(r, json!({"maximized": false})); + assert!(cmds.contains(&egui::ViewportCommand::Maximized(false)), "{cmds:?}"); + } + #[test] fn dispatch_uses_the_active_custom_preset() { let mut app = EffectcraftApp::new(effectcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 8f249b780..21f842976 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -599,12 +599,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { actions.push(("layer.addItem".into(), json!({"item": id.0}))); ui.close(); } + // File ▸ New Comp from Selection on the selection (the right-clicked row joins it + // above): several items ask how in its dialog, as in After Effects (#360). if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() { - if let Some((w, h)) = it.dimensions() { - let d = it.duration().map(|d| d.seconds()).unwrap_or(10.0); - actions.push(("comp.new".into(), json!({"name": format!("{} Comp", it.name), "width": w, "height": h, "duration": d}))); - actions.push(("layer.addItem".into(), json!({"item": id.0}))); - } + actions.push(("file.newCompFromSelection".into(), json!({}))); ui.close(); } ui.separator(); diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index 1ed3f21e0..47aacc46f 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -682,10 +682,15 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui: l } }; - let o = pos2(resp.rect.max.x - 90.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + // Scaled and placed to fit the row's preview column, and clipped to the row (#577). + let room = Rect::from_min_max(pos2(resp.rect.max.x - 95.0, resp.rect.min.y + 1.0), pos2(resp.rect.max.x - 6.0, resp.rect.max.y - 1.0)); + if let Some((k, o)) = fit_preview(&lines, room, resp.rect.center().y + 5.0) { + let painter = ui.painter().with_clip_rect(resp.rect.intersect(ui.clip_rect())); + for poly in lines.iter() { + let pts: Vec = + poly.iter().filter(|p| p[0].is_finite() && p[1].is_finite()).map(|p| (o + vec2(p[0], p[1]) * k).to_pos2()).collect(); + painter.add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); + } } } if resp.clicked() { @@ -837,3 +842,63 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token p.rect_filled(Rect::from_min_size(pos2(c.x + 3.0, off(9.0)), vec2(5.0, 9.0)), 1.0, t.text_dim); } } + +/// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`, +/// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit; +/// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview +/// spills out of its row (#577). `None`: nothing to draw. +fn fit_preview(lines: &[Vec<[f32; 2]>], room: Rect, baseline: f32) -> Option<(f32, egui::Vec2)> { + let b = lines + .iter() + .flatten() + .filter(|p| p[0].is_finite() && p[1].is_finite()) + .fold(Rect::NOTHING, |b, p| b.union(Rect::from_min_max(pos2(p[0], p[1]), pos2(p[0], p[1])))); + if !(b.min.x <= b.max.x && b.min.y <= b.max.y) || !room.is_positive() { + return None; + } + let k = (room.width() / b.width().max(1e-3)).min(room.height() / b.height().max(1e-3)).min(1.0); + let mut o = vec2(room.min.x - b.min.x * k, baseline); + let (top, bottom) = (o.y + b.min.y * k, o.y + b.max.y * k); + if top < room.min.y { + o.y += room.min.y - top; + } else if bottom > room.max.y { + o.y -= bottom - room.max.y; + } + Some((k, o)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// #577: every font's "Sample" preview lands inside its row's preview box: at its natural size + /// when it fits, else scaled down; nothing for no outlines. + #[test] + fn font_previews_fit_their_row() { + let room = Rect::from_min_max(pos2(180.0, 101.0), pos2(294.0, 119.0)); + let baseline = 115.0; + let inside = |lines: &[Vec<[f32; 2]>]| { + let (k, o) = fit_preview(lines, room, baseline).expect("a preview"); + for p in lines.iter().flatten() { + let q = o + vec2(p[0], p[1]) * k; + assert!(room.expand(0.01).contains(pos2(q.x, q.y)), "{p:?} → {q:?} outside {room:?}"); + } + k + }; + // A plain 14 px sample fits as it is, on the baseline. + let plain = vec![vec![[0.0, -10.0], [60.0, -10.0], [60.0, 3.0], [0.0, 3.0]]]; + assert_eq!(inside(&plain), 1.0); + assert_eq!(fit_preview(&plain, room, baseline).map(|(_, o)| o.y), Some(baseline)); + // Huge, wide, tall, offset and negative-bearing outlines are scaled into the box. + for lines in [ + vec![vec![[0.0, -80.0], [400.0, 30.0]]], + vec![vec![[-50.0, -5.0], [500.0, 2.0]]], + vec![vec![[10.0, -200.0], [12.0, 0.0]]], + vec![vec![[0.0, 20.0], [30.0, 40.0]]], + ] { + assert!(inside(&lines) <= 1.0); + } + assert!(fit_preview(&[], room, baseline).is_none()); + assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none()); + } +} diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 081e136dc..36df6174a 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1644,6 +1644,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => { if pick(app, &ectx, cpt, mods.shift).is_none() { let _ = app.session.execute("edit.deselectAll", json!({})); + } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() { + // A click away from the paths, even on their layer, deselects the mask + // points (the layer stays selected), as in After Effects (#513). + let _ = app.session.execute("mask.selectVertices", json!({"vertices": []})); } } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 0b811ffbd..ea76f06b1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -278,6 +278,8 @@ fn field(ui: &mut Ui, rect: Rect, text: &mut String, hint: &str, t: &Tokens, sea .hint_text(hint) .frame(egui::Frame::NONE) .desired_width(inner.width()) + // As tall as the field, so the text and the hint sit on its middle (they sat high, #581). + .min_size(inner.size()) .font(Tokens::ui(12.0)) .vertical_align(egui::Align::Center), ) @@ -565,3 +567,27 @@ pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { pub fn open_popup(ui: &Ui, id: egui::Id) { ui.data_mut(|d| d.insert_temp(id.with("open"), true)); } + +#[cfg(test)] +mod tests { + use super::*; + + /// #581: a field's text edit fills it, so its text and hint sit on the field's middle line + /// (it was a row at the top of the field, its text high). + #[test] + fn field_text_is_vertically_centred() { + let ctx = egui::Context::default(); + let t = Tokens::for_kind(Default::default()); + for h in [22.0, 26.0, 30.0] { + let field = Rect::from_min_size(pos2(20.0, 40.0), vec2(220.0, h)); + let mut text = String::new(); + let mut got = None; + let mut out = ctx.run_ui(Default::default(), |ui| { + got = Some(search_field(ui, field, &mut text, "Filter recent files", &t).rect); + }); + out.textures_delta.clear(); + let r = got.expect("the field was drawn"); + assert!((r.center().y - field.center().y).abs() < 0.5 && r.height() >= h - 2.5, "{h}: {r:?} in {field:?}"); + } + } +} diff --git a/crates/ui-egui/tests/ui_panels.rs b/crates/ui-egui/tests/ui_panels.rs index d58c2146d..3b580921a 100644 --- a/crates/ui-egui/tests/ui_panels.rs +++ b/crates/ui-egui/tests/ui_panels.rs @@ -35,6 +35,17 @@ fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) { h.run_steps(2); } +/// #581: the header's Discord pill fits its label with even padding (it was a fixed 104 px with +/// the text at its left). +#[test] +fn discord_pill_fits_its_label() { + let h = harness(); + let e = h.state().auto.find("header.discord").expect("the Discord pill").clone(); + let text = h.ctx.fonts_mut(|f| f.layout_no_wrap("Discord".into(), effectcraft_ui_egui::theme::Tokens::semibold(12.0), egui::Color32::WHITE).size().x); + // Padding 10, the 14 px icon, a 6 px gap, the text, padding 12. + assert!((e.rect[2] - (text + 42.0)).abs() < 0.5, "{} for a {text} px label", e.rect[2]); +} + #[test] fn window_menu_opens_the_new_panels() { let mut h = harness(); diff --git a/crates/ui-egui/tests/ui_project_rows.rs b/crates/ui-egui/tests/ui_project_rows.rs index 43daf2ed1..4c255cadd 100644 --- a/crates/ui-egui/tests/ui_project_rows.rs +++ b/crates/ui-egui/tests/ui_project_rows.rs @@ -148,6 +148,32 @@ fn footage_context_menu_interprets_the_footage() { assert_eq!(h.state().dialog, Some(Dialog::Form), "the Interpret Footage dialog opened"); } +/// #360: the row menu's New Comp from Selection works on the whole selection, as File ▸ New +/// Comp from Selection does: with several items selected it asks how in After Effects' New +/// Composition from Selection dialog, and Multiple Compositions makes one per item. +#[test] +fn context_menu_new_comp_from_selection_uses_the_selection() { + let (mut h, _, comp, solid) = harness(); + h.state_mut().session.state.project_selection = vec![comp, solid]; + h.run_steps(2); + let at = center(&h, &format!("project.item.{}.name", solid.0)); + h.input_mut().events.push(Event::PointerMoved(at)); + h.step(); + for pressed in [true, false] { + h.input_mut().events.push(Event::PointerButton { pos: at, button: egui::PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(2); + assert_eq!(h.state().session.state.project_selection, vec![comp, solid], "the selection stays"); + let item = h.query_by_label_contains("New Comp from Selection").expect("in the menu").rect(); + click_at(&mut h, item.center()); + assert_eq!(h.state().dialog, Some(Dialog::Form)); + assert!(h.state().auto.find("form.field.single").is_some(), "the New Composition from Selection dialog"); + let comps = h.state().session.project.comps().count(); + h.state_mut().session.execute_checked("file.newCompFromSelection", json!({"single": false})).unwrap(); + assert_eq!(h.state().session.project.comps().count(), comps + 2, "one per item"); +} + /// Dragging from the Project panel's empty area draws a selection box that selects the rows it /// touches; a click there deselects (#203). #[test] diff --git a/crates/ui-egui/tests/ui_viewer.rs b/crates/ui-egui/tests/ui_viewer.rs index 6b535b6cc..fd12d5553 100644 --- a/crates/ui-egui/tests/ui_viewer.rs +++ b/crates/ui-egui/tests/ui_viewer.rs @@ -750,6 +750,26 @@ fn pen_mask_undoes_step_by_step_and_double_click_moves_the_whole_mask() { assert!(back.vertices.iter().zip(&moved.vertices).all(|(v, c)| near(*v, *c)), "{back:?}"); } +/// #513: clicking away from a mask's path, even on its own layer, deselects the mask's points; +/// the layer stays selected. +#[test] +fn clicking_off_a_mask_deselects_its_points() { + let mut h = harness(); + let plate = layer_id(&h, "Plate"); + let s = &mut h.state_mut().session; + s.execute("layer.select", json!({"layers": [plate]})).unwrap(); + s.execute("mask.new", json!({"layer": plate, "vertices": [[100.0, 60.0], [500.0, 60.0], [500.0, 300.0], [100.0, 300.0]], "closed": true})).unwrap(); + h.state_mut().ui.tool = Tool::Selection; + h.run_steps(2); + let at = screen(&h, [500.0, 60.0]); + click(&mut h, at); + assert_eq!(h.state().session.state.selected_vertices.len(), 1, "a click on a point selects it"); + let away = screen(&h, [200.0, 250.0]); + click(&mut h, away); + assert!(h.state().session.state.selected_vertices.is_empty(), "a click off the path deselects its points"); + assert_eq!(h.state().session.state.selected_layers, vec![LayerId(plate)], "the layer stays selected"); +} + /// A straight two-key motion path shows Bezier handles at both keys (it had none), and dragging /// one pulls the path into a curve (#290). #[test]