mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 04:26:55 -06:00
Merge branch 'fix/f1': community issue fixes
# Conflicts: # crates/ui-egui/src/panels/text_panels.rs
This commit is contained in:
@@ -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!({}));
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": []}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user