Merge branch 'fix/f1': community issue fixes

# Conflicts:
#	crates/ui-egui/src/panels/text_panels.rs
This commit is contained in:
Claude
2026-10-10 19:48:18 +00:00
9 changed files with 204 additions and 12 deletions
+6 -3
View File
@@ -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!({}));
+39
View File
@@ -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());
+3 -5
View File
@@ -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();
+69 -4
View File
@@ -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<egui::Pos2> = 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<egui::Pos2> =
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());
}
}
+4
View File
@@ -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": []}));
}
}
}
+26
View File
@@ -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:?}");
}
}
}
+11
View File
@@ -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();
+26
View File
@@ -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]
+20
View File
@@ -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]