Merge main into #2020

This commit is contained in:
bflatastic
2026-11-07 08:29:49 +07:00
20 changed files with 434 additions and 29 deletions
+8
View File
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
@@ -33,6 +40,7 @@ jobs:
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+2 -1
View File
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -302,7 +302,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
}
(w, h, offset) = (shape.width, shape.height, shape.offset);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
let pts: Vec<Point> = pts
.iter()
.map(|v| {
let xy = v.as_array().filter(|a| a.len() == 2)?;
Some(Point::new(f64_of(&xy[0])?, f64_of(&xy[1])?))
})
.collect::<Option<_>>()
.ok_or_else(|| bad(cmd, "`points` entries must be numeric [x, y] pairs"))?;
if pts.len() < 2 {
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
}
+1 -1
View File
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("page.move", "Move page", "{id?, index}", true, |s, p| {
let id = id(p, "id").unwrap_or(s.page()?);
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
let k = req_index("page.move", p, "index")?;
let doc = s.doc_mut()?;
if !doc.pages.contains(&id) {
return Err(bad("page.move", format!("{id} is not a page")));
+3 -3
View File
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
use super::lists::BoundList;
use super::{CommandSpec, ok};
use crate::params::{boolean, get, num, req_string, string};
use crate::params::{boolean, get, req_index, req_string, string};
use crate::{Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
@@ -258,13 +258,13 @@ fn remove_mode(s: &mut Session, p: &Value) -> Result<Value> {
fn move_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.moveMode";
let to = num(p, "to").filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`to` must be a number"))?;
let to = req_index(cmd, p, "to")?;
let doc = s.doc_mut()?;
let (ci, mode) = mode_of(doc, cmd, p)?;
let coll = doc.variables.collections.get_mut(ci).ok_or_else(|| bad(cmd, "no such collection"))?;
let from = coll.modes.iter().position(|m| m.id == mode).ok_or_else(|| bad(cmd, "no such mode"))?;
let m = coll.modes.remove(from);
let at = (to.max(0.0) as usize).min(coll.modes.len());
let at = to.min(coll.modes.len());
coll.modes.insert(at, m);
ok()
}
+9
View File
@@ -378,3 +378,12 @@ fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() {
let t = editing(&s).expect("typing starts on the path");
assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable");
}
#[test]
fn bad_point_is_rejected() {
let mut s = Session::new();
assert!(s.execute("vector.create", &json!({"points": [[0, 0], ["abc", 5], [100, 100]], "closed": false})).is_err());
assert!(s.execute("vector.create", &json!({"points": [[0, 0], [1], [100, 100]]})).is_err());
assert!(s.execute("vector.create", &json!({"points": [[0, 0], 7, [100, 100]]})).is_err());
assert!(s.execute("vector.create", &json!({"points": [[0, 0], [50, 5], [100, 100]]})).is_ok());
}
@@ -398,6 +398,20 @@ fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() {
}
}
#[test]
fn page_move_refuses_negative_or_fractional_indexes() {
let mut s = Session::new();
ex(&mut s, "page.add", json!({}));
let p3 = id(&ex(&mut s, "page.add", json!({})));
let before = pages(&mut s);
for bad_index in [json!(-1), json!(1.5)] {
assert!(s.execute("page.move", &json!({"id": p3, "index": bad_index})).is_err(), "page.move index {bad_index} must fail");
assert_eq!(pages(&mut s), before, "page.move index {bad_index} leaves the order alone");
}
ex(&mut s, "page.move", json!({"id": p3, "index": 0}));
assert_eq!(pages(&mut s)[0]["id"], json!(p3), "a valid index still moves the page");
}
#[test]
fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
let mut s = Session::new();
@@ -68,6 +68,20 @@ fn types_and_their_starting_values() {
assert!(s.execute("variable.create", &json!({"collection": "C", "name": "y", "type": "number", "value": "wide"})).is_err());
}
#[test]
fn move_mode_rejects_negative_and_fractional_indexes() {
let mut s = Session::new();
theme(&mut s);
run(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"}));
let order =
|s: &Session| -> Vec<String> { s.doc().unwrap().variables.collection("Theme").unwrap().modes.iter().map(|m| m.name.clone()).collect() };
let before = order(&s);
for to in [json!(-1), json!(1.9)] {
assert!(s.execute("variable.moveMode", &json!({"collection": "Theme", "mode": "Dim", "to": to})).is_err(), "{to}");
assert_eq!(order(&s), before, "{to}");
}
}
#[test]
fn modes_new_duplicated_reordered_and_the_default() {
// Documented: a new mode starts with the default mode's values and a duplicated one with its
+140
View File
@@ -0,0 +1,140 @@
//! The Design panel's boolean operation menu leads each entry with its operation's icon (#2003),
//! and each entry still runs its command: drawn headless.
use egui::accesskit::Node;
use egui::{Event, PointerButton, Pos2, Rect, Shape, pos2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::icons::Icon;
use crate::layers_drag_tests::Ui;
const OPS: [(&str, Icon, &str); 4] = [
("Union", Icon::Union, "bool.union"),
("Subtract", Icon::Subtract, "bool.subtract"),
("Intersect", Icon::Intersect, "bool.intersect"),
("Exclude", Icon::Exclude, "bool.exclude"),
];
fn id(v: serde_json::Value) -> NodeId {
serde_json::from_value(v["id"].clone()).unwrap()
}
/// Two overlapping layers selected, headless.
fn two_layers() -> Ui {
let mut s = Session::new();
let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let b = id(s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let mut u = Ui::new(s);
u.frame(vec![]);
u
}
fn nodes(u: &mut Ui) -> Vec<Node> {
u.ctx.enable_accesskit();
u.frame(vec![]);
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
}
fn bounds(n: &Node) -> Rect {
let b = n.bounds().unwrap();
Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
}
fn click(u: &mut Ui, at: Pos2) {
for pressed in [true, false] {
u.frame(vec![
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() },
]);
}
}
/// The menu open: the nodes on screen.
fn open_menu(u: &mut Ui) -> Vec<Node> {
let all = nodes(u);
let button = all.iter().find(|n| n.label() == Some("Boolean groups")).expect("the Design panel's Boolean groups button");
click(u, bounds(button).center());
for _ in 0..10 {
u.frame(vec![]);
}
nodes(u)
}
/// How many strokes and fills sit in the left 40 points of `row` (where the icon is).
fn marks_in_lead(shapes: &[egui::epaint::ClippedShape], row: Rect) -> usize {
fn walk(s: &Shape, area: Rect, n: &mut usize) {
match s {
Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, n)),
Shape::Path(_) | Shape::Mesh(_) | Shape::Rect(_) | Shape::LineSegment { .. } if area.contains_rect(s.visual_bounding_rect()) => *n += 1,
_ => {}
}
}
let area = Rect::from_min_max(row.min, pos2(row.min.x + 40.0, row.max.y));
let mut n = 0;
shapes.iter().for_each(|c| walk(&c.shape, area, &mut n));
n
}
#[test]
fn the_boolean_menu_leads_every_operation_with_its_icon() {
let mut u = two_layers();
let all = open_menu(&mut u);
let out = u.frame_output(vec![]);
for (name, _, _) in OPS {
let row = all.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} in the menu"));
let n = marks_in_lead(&out.shapes, bounds(row));
assert!(n >= 2, "{name}'s icon painted at the start of its row ({n} marks)");
}
}
#[test]
fn every_boolean_menu_entry_still_runs_its_command() {
for (name, icon, _) in OPS {
let mut u = two_layers();
let all = open_menu(&mut u);
let row = all.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} in the menu"));
click(&mut u, bounds(row).center());
for _ in 0..3 {
u.frame(vec![]);
}
let sel = u.app.session.selection();
assert_eq!(sel.len(), 1, "{name} made one boolean group");
assert_eq!(crate::layers::node_icon(u.app.session.doc().unwrap(), sel[0]), icon, "{name}'s group shows {icon:?}");
}
}
/// The x every label (checked or not, with an icon or without, Flatten) starts at.
fn label_xs(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, f32)> {
fn walk(s: &Shape, out: &mut Vec<(String, f32)>) {
match s {
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
Shape::Text(t) => out.push((t.galley.text().to_owned(), t.pos.x)),
_ => {}
}
}
let mut out = Vec::new();
shapes.iter().for_each(|c| walk(&c.shape, &mut out));
out
}
#[test]
fn every_boolean_menu_label_starts_at_the_same_x() {
for checked in [false, true] {
let mut u = two_layers();
if checked {
u.app.run("bool.union", json!({}));
u.frame(vec![]);
}
open_menu(&mut u);
let out = u.frame_output(vec![]);
let xs = label_xs(&out.shapes);
let at: Vec<f32> = ["Union", "Subtract", "Intersect", "Exclude", "Flatten"]
.iter()
.map(|n| xs.iter().rev().find(|(t, _)| t == n).unwrap_or_else(|| panic!("{n} label (checked: {checked})")).1)
.collect();
assert!(at.iter().all(|x| (x - at[0]).abs() < 0.01), "labels line up (checked: {checked}): {at:?}");
}
}
+24
View File
@@ -378,6 +378,30 @@ mod tests {
}
}
/// The four boolean operation icons each paint shapes and no two paint the same ones, so the
/// buttons can be told apart (#2003).
#[test]
fn the_boolean_operation_icons_paint_and_differ() {
let ctx = egui::Context::default();
let ops = [Icon::Union, Icon::Subtract, Icon::Intersect, Icon::Exclude];
let painted: Vec<String> = ops
.iter()
.map(|&icon| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(16.0)), icon, Color32::WHITE);
});
out.textures_delta.clear();
assert!(!out.shapes.is_empty(), "{icon:?} paints at least one shape");
format!("{:?}", out.shapes.iter().map(|c| &c.shape).collect::<Vec<_>>())
})
.collect();
for i in 0..ops.len() {
for j in (i + 1)..ops.len() {
assert_ne!(painted[i], painted[j], "{:?} and {:?} paint different shapes", ops[i], ops[j]);
}
}
}
#[test]
fn concave_polygons_triangulate() {
let arrow = [pos2(4.0, 2.0), pos2(4.0, 13.6), pos2(6.9, 10.9), pos2(8.8, 14.8), pos2(10.6, 14.0), pos2(8.7, 10.2), pos2(12.6, 10.2)];
+18 -9
View File
@@ -88,6 +88,11 @@ impl P<'_> {
// A hairline around the mesh gives it the same soft edge as the convex fills.
self.painter.add(Shape::closed_line(v, Stroke::new(self.px, self.color)));
}
/// A polygon filled in the icon colour at reduced alpha, so the strokes over it stay readable.
fn fill_soft(&self, pts: &[(f32, f32)]) {
let soft = P { painter: self.painter, o: self.o, s: self.s, px: self.px, stroke: self.stroke, color: self.color.gamma_multiply(0.4) };
soft.fill(pts);
}
/// Triangles `tris` over `verts` (x, y, alpha): the colour fades between the vertices.
fn shade(&self, verts: &[(f32, f32, f32)], tris: &[u32]) {
let mut mesh = Mesh::default();
@@ -427,24 +432,28 @@ fn draw(p: &P, icon: Icon) {
p.fill_rect(11.5, 2.0, 14.5, 5.0, 0.0);
}
Boolean | Union => {
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
// Two squares offset diagonally: the outline of both together, filled.
p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (2.5, 9.5)]);
p.poly(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (2.5, 9.5)], true);
}
Subtract => {
p.fill_rect(2.0, 2.0, 10.0, 10.0, 0.0);
// The first square less the overlap, filled; both squares outlined.
p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (6.5, 6.5), (6.5, 9.5), (2.5, 9.5)]);
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
}
Intersect => {
// Only the overlap filled; both squares outlined.
p.fill_soft(&[(6.5, 6.5), (9.5, 6.5), (9.5, 9.5), (6.5, 9.5)]);
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
p.fill_rect(6.0, 6.0, 10.0, 10.0, 0.0);
}
Exclude => {
p.fill_rect(2.0, 2.0, 10.0, 6.0, 0.0);
p.fill_rect(2.0, 6.0, 6.0, 10.0, 0.0);
p.fill_rect(10.0, 6.0, 14.0, 14.0, 0.0);
p.fill_rect(6.0, 10.0, 10.0, 14.0, 0.0);
p.rect(6.5, 6.5, 9.5, 9.5, 0.0);
// Both squares less the overlap, filled; both squares outlined.
p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (6.5, 6.5), (6.5, 9.5), (2.5, 9.5)]);
p.fill_soft(&[(13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (9.5, 9.5), (9.5, 6.5)]);
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
}
Page => {
p.poly(&[(3.5, 2.5), (9.5, 2.5), (12.5, 5.5), (12.5, 13.5), (3.5, 13.5)], true);
+4
View File
@@ -54,6 +54,8 @@ mod async_dialog_tests;
#[cfg(test)]
mod blend_preview_tests;
#[cfg(test)]
mod bool_menu_icon_tests;
#[cfg(test)]
mod branch_ui_tests;
#[cfg(test)]
mod canvas_double_click_tests;
@@ -253,6 +255,8 @@ mod ui_params_tests;
#[cfg(test)]
mod ui_scale_tests;
#[cfg(test)]
mod variable_picker_tests;
#[cfg(test)]
mod variables_export_ui_tests;
#[cfg(test)]
mod variables_rename_tests;
+5 -6
View File
@@ -35,7 +35,7 @@ pub(crate) use inspect::{inspect_section, prop_row};
pub(crate) use menu::menu_row_width;
#[cfg(test)]
pub(crate) use menu::preview_blend;
pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row};
pub use menu::{blend_menu, context_popup, menu_check, menu_icon_item, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row};
pub(crate) use menu::{block, menu_row_lit, menu_style, row, row_rect};
use paints::{appearance, paints, variable_modes};
pub(crate) use stroke::{CAP_ART_W, cap_art, join_label};
@@ -483,18 +483,17 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
(BoolOp::Intersect, "bool.intersect"),
(BoolOp::Exclude, "bool.exclude"),
] {
// Each entry leads with its operation's icon (the result it previews).
let key = crate::shortcuts::shortcut(c).unwrap_or("");
let picked = match current {
Some(cur) => menu_check(ui, cur == op, tr(op.label()), key),
None => menu_item(ui, tr(op.label()), key),
};
let icon = crate::layers::op_icon(op);
let picked = menu_icon_item(ui, Some(icon), current.map(|cur| cur == op), tr(op.label()), key);
if picked {
app.run(c, json!({}));
ui.close();
}
}
let key = crate::shortcuts::shortcut("vector.flatten").unwrap_or("");
let flatten = if current.is_some() { menu_item_beside_checks(ui, tr("Flatten"), key) } else { menu_item(ui, tr("Flatten"), key) };
let flatten = menu_icon_item(ui, None, None, tr("Flatten"), key);
if flatten {
app.run("vector.flatten", json!({}));
ui.close();
+36 -5
View File
@@ -81,22 +81,30 @@ pub(crate) fn menu_row_width(label_w: f32, trail: f32) -> f32 {
/// A menu row (see [`menu_item_with`]); its response tells hovering too. It spans the menu and
/// is at least as wide as its label, shortcut and art.
pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response {
lit_row(ui, label, shortcut, art_w, art, false)
lit_row(ui, label, shortcut, art_w, 0.0, art, false)
}
/// A plain menu row highlighted when `lit`, as if hovered: the row Up and Down are on in a menu
/// whose search field keeps the keyboard.
pub(crate) fn menu_row_lit(ui: &mut Ui, label: &str, lit: bool) -> egui::Response {
lit_row(ui, label, "", 0.0, |_, _, _| {}, lit)
lit_row(ui, label, "", 0.0, 0.0, |_, _, _| {}, lit)
}
fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32), lit: bool) -> egui::Response {
fn lit_row(
ui: &mut Ui,
label: &str,
shortcut: &str,
art_w: f32,
lead: f32,
art: impl FnOnce(&egui::Painter, Rect, Color32),
lit: bool,
) -> egui::Response {
let t = Tokens::get(ui.ctx());
let text = ui.painter().layout_no_wrap(label.to_owned(), regular(11.5), Color32::PLACEHOLDER);
let keys =
(!shortcut.is_empty()).then(|| ui.painter().layout_no_wrap(crate::toolbar::pretty_shortcut(shortcut), regular(11.0), Color32::PLACEHOLDER));
let trail = keys.as_ref().map_or(0.0, |k| k.size().x + MENU_PAD).max(art_w);
let w = ui.available_width().max(menu_row_width(text.size().x, trail).ceil());
let w = ui.available_width().max(menu_row_width(lead + text.size().x, trail).ceil());
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
a11y::menu_item(&resp, label, shortcut, None);
// The keyboard's item is highlighted like the hovered one.
@@ -108,7 +116,7 @@ fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnc
// These menus sit on the theme's popup fill (white in the light theme), not on the dark
// `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972).
let col = if hovered { t.menu_text } else { t.text };
ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col);
ui.painter().galley(pos2(r.min.x + MENU_PAD + lead, r.center().y - text.size().y / 2.0), text, col);
if let Some(k) = keys {
let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0);
ui.painter().galley(at, k, if hovered { t.menu_text } else { t.text_secondary });
@@ -154,3 +162,26 @@ pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)
add(&mut child);
r
}
/// Width of the check column of [`menu_icon_item`] (the mark, or nothing, so labels line up).
const CHECK_COL: f32 = 14.0;
/// A menu item in a menu whose entries lead with a 16 px icon (in the row's text colour): the
/// boolean operations in the Design panel. `icon` is `None` for an entry without one (Flatten):
/// its slot stays empty, so every label starts at the same x. The check mark (`on` is
/// `Some(true)`) is drawn in its own column ahead of the icon, so a checked entry's label does
/// not move against the unchecked ones.
pub fn menu_icon_item(ui: &mut Ui, icon: Option<crate::icons::Icon>, on: Option<bool>, label: &str, shortcut: &str) -> bool {
let paint = |p: &egui::Painter, r: Rect, col: Color32| {
if on == Some(true) {
let mark = p.layout_no_wrap("✓".to_owned(), regular(11.5), col);
p.galley(pos2(r.min.x + MENU_PAD, r.center().y - mark.size().y / 2.0), mark, col);
}
if let Some(icon) = icon {
crate::icons::paint(p, Rect::from_min_size(pos2(r.min.x + MENU_PAD + CHECK_COL, r.center().y - 8.0), vec2(16.0, 16.0)), icon, col);
}
};
let resp = lit_row(ui, label, shortcut, 0.0, CHECK_COL + 22.0, paint, false);
a11y::menu_item(&resp, label, shortcut, on);
resp.clicked()
}
+3
View File
@@ -423,6 +423,9 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
let mut chosen: Option<Pick> = None;
let mut close = false;
let area = egui::Area::new(Id::unique("variable_picker_area")).order(egui::Order::Foreground).fixed_pos(p.at + vec2(0.0, 4.0)).show(ctx, |ui| {
// The whole screen's height, not last frame's, so the list grows back after a search (#2043);
// egui keeps the area on the screen.
ui.set_max_height(crate::popover::max_height(ctx.content_rect()));
egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
ui.set_width(240.0);
ui.horizontal(|ui| {
+11
View File
@@ -177,6 +177,7 @@ impl Popover {
ui.set_invisible();
ctx.request_repaint();
}
ui.set_max_height(max_height(screen));
// The panel is painted into a place kept before the content: the shadow and the fill
// go under it, though its size is only known once the slot is laid out.
let bg = ui.painter().add(egui::Shape::Noop);
@@ -276,6 +277,16 @@ fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {
pos2(anchor.x - size.x, y)
}
/// The height a popover's content is offered: the screen's, less [`EDGE`] above and below.
///
/// egui offers an area the size it had last frame, so content that fills what it is offered (a
/// scrolling list) could only ever shrink: a search that narrowed a picker's list left it a few
/// rows high after the search was cleared (#2043). The list takes what it needs of this, and the
/// placement keeps the panel on the screen.
pub(crate) fn max_height(screen: Rect) -> f32 {
(screen.height() - 2.0 * EDGE).max(HEADER_H)
}
/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't.
fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 {
let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE);
@@ -0,0 +1,67 @@
//! The variable picker ("Select variable" on every bindable field, aliases, styles and prototype
//! actions) drawn headless: its list follows its rows while it is open — a search that narrows it
//! shrinks the picker, clearing the search grows it back — and it stays on the screen (#2043: egui
//! offers an area the size it had last frame, so the list used to stay a few rows high).
use egui::{Id, Pos2, RawInput, Rect, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
/// A selected rectangle and 30 number variables.
fn scene() -> (Ui, NodeId) {
let mut s = Session::new();
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
s.execute("variable.createCollection", &json!({"name": "Sizes"})).unwrap();
for i in 0..30 {
s.execute("variable.create", &json!({"collection": "Sizes", "name": format!("w-{}", 100 + 10 * i), "type": "number", "value": i})).unwrap();
}
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
(Ui::new(s), r)
}
/// Opens the picker for `r`'s stroke weight under `at`.
fn open(u: &mut Ui, r: NodeId, at: Pos2) {
let screen = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() };
let _ = u.ctx.run_ui(screen, |ui| crate::variable_ui::open(ui, &[r], "strokeWeight", figcraft_doc::VariableType::Number, at));
}
/// The picker's rect after a few frames.
fn settled(u: &mut Ui) -> Rect {
for _ in 0..5 {
u.frame(vec![]);
}
crate::variable_ui::picker_rect(&u.ctx).expect("the variable picker shows")
}
fn search(u: &mut Ui, q: &str) {
u.ctx.data_mut(|d| d.insert_temp(Id::unique("variable_picker_query"), q.to_string()));
}
#[test]
fn the_variable_list_grows_back_after_a_search_narrows_it() {
let (mut u, r) = scene();
open(&mut u, r, pos2(900.0, 200.0));
let open = settled(&mut u);
assert!(open.height() > 300.0, "the picker lists the variables: {open:?}");
search(&mut u, "w-100");
let narrowed = settled(&mut u);
assert!(narrowed.height() < open.height() - 100.0, "a search for one variable shrinks it: {open:?} → {narrowed:?}");
search(&mut u, "");
let again = settled(&mut u);
assert!((again.height() - open.height()).abs() < 1.0, "clearing the search grows it back: {open:?} → {again:?}");
}
#[test]
fn the_variable_picker_opened_near_the_bottom_shows_its_whole_list_on_the_screen() {
let (mut u, r) = scene();
open(&mut u, r, pos2(900.0, 200.0));
let full = settled(&mut u);
let (mut u, r) = scene();
open(&mut u, r, pos2(900.0, 880.0));
let low = settled(&mut u);
assert!((low.height() - full.height()).abs() < 1.0, "as tall as higher up: {full:?} vs {low:?}");
assert!(low.max.y <= 900.0 && low.min.y >= 0.0, "on the screen: {low:?}");
}
@@ -194,6 +194,68 @@ fn a_click_in_the_colour_picker_keeps_the_style_editor_open() {
);
}
/// #2043: the list follows its rows while the picker is open. A search that narrows it shrinks
/// the popover, and clearing the search grows it back to the full list (egui offers an area the
/// size it had last frame, so the list used to stay a few rows high).
#[test]
fn the_style_list_grows_back_after_a_search_narrows_it() {
use crate::layers_drag_tests::Ui;
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
for i in 0..30 {
let name = format!("Blue/{}", 100 + 10 * i);
s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap();
}
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
open_for_selection(&mut u.app, StyleSlot::Fill, None);
let height = |u: &mut Ui| {
for _ in 0..5 {
u.frame(vec![]);
}
u.app.ui.style_picker_popover.as_ref().expect("the style picker shows").rect.height()
};
let open = height(&mut u);
assert!(open > 300.0, "the picker lists the styles: {open}");
u.app.ui.style_picker.as_mut().expect("open").query = "Blue/100".into();
let narrowed = height(&mut u);
assert!(narrowed < open - 100.0, "a search for one style shrinks it: {open} → {narrowed}");
u.app.ui.style_picker.as_mut().expect("open").query.clear();
let again = height(&mut u);
assert!((again - open).abs() < 1.0, "clearing the search grows it back: {open} → {again}");
}
/// #2043: egui keeps an area's size after it closes, so a picker opened once on a short list (the
/// effect styles, none yet) opened again on the colour styles a few rows high.
#[test]
fn the_style_list_opened_after_a_short_one_shows_every_style_it_fits() {
use crate::layers_drag_tests::Ui;
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
for i in 0..30 {
let name = format!("Blue/{}", 100 + 10 * i);
s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap();
}
s.execute("select.set", &json!({"ids": [r]})).unwrap();
let mut u = Ui::new(s);
let height = |u: &mut Ui, slot: StyleSlot| {
open_for_selection(&mut u.app, slot, None);
for _ in 0..5 {
u.frame(vec![]);
}
let h = u.app.ui.style_picker_popover.as_ref().expect("the style picker shows").rect.height();
u.app.ui.style_picker = None;
u.app.ui.style_picker_popover = None;
u.frame(vec![]);
h
};
let first = height(&mut u, StyleSlot::Fill);
let short = height(&mut u, StyleSlot::Effect);
assert!(short < first - 100.0, "no effect styles: {short}");
let again = height(&mut u, StyleSlot::Fill);
assert!((again - first).abs() < 1.0, "the colour styles as tall as the first time: {first} → {again}");
}
#[test]
fn local_style_drops_move_into_the_folder_above() {
let st = |id: &str, name: &str| Style {
+2 -1
View File
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username; never hand-edit.
username; never hand-edit. Release builds regenerate it for the release commit
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
+3 -2
View File
File diff suppressed because one or more lines are too long