mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 04:39:38 -06:00
Merge main into #2048
This commit is contained in:
@@ -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
File diff suppressed because one or more lines are too long
@@ -104,3 +104,7 @@ real_name = "Oleksii Sukholita"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.denisg0mes]
|
||||
real_name = "Denis Gomes"
|
||||
display_name = "Denis Gomes"
|
||||
|
||||
@@ -1352,6 +1352,15 @@ pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> {
|
||||
doc.libraries.import_of(root).map(|r| r.library.as_str())
|
||||
}
|
||||
|
||||
pub fn library_source(doc: &Document, id: NodeId) -> Option<(&str, NodeId)> {
|
||||
if !doc.is_remote(id) {
|
||||
return None;
|
||||
}
|
||||
let record = doc.libraries.import_of(asset_root(doc, id)?)?;
|
||||
let key = record.ids.iter().find(|(_, local)| **local == id).map_or(record.key, |(key, _)| *key);
|
||||
Some((record.library.as_str(), key))
|
||||
}
|
||||
|
||||
/// The display name of library `id` in `doc` (its link's name, else the id).
|
||||
pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str {
|
||||
doc.libraries.link(id).map_or(id, |l| l.name.as_str())
|
||||
|
||||
@@ -189,8 +189,6 @@ fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
@@ -399,6 +397,75 @@ fn add_place_and_update_keep_overrides() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
fn go_to_main(s: &mut Session, id: NodeId) -> Value {
|
||||
s.active = 0;
|
||||
ex(s, "component.goToMain", json!({"id": id}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_to_a_library_main_opens_the_library_file() {
|
||||
let d = dir("go-to-main");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
let k = kit(&mut lib);
|
||||
let mut chips = Vec::new();
|
||||
for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() {
|
||||
let f = idv(&ex(&mut lib, "frame.create", json!({"x": 400.0 + i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"})));
|
||||
ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}}));
|
||||
chips.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
ex(&mut lib, "component.combineVariants", json!({"ids": chips}));
|
||||
let kit_page = lib.doc().unwrap().page_of(k.card).unwrap();
|
||||
ex(&mut lib, "page.add", json!({"name": "Notes"}));
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let card = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0})));
|
||||
let nested_button = node(&s, card).children[0];
|
||||
let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 200})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}}));
|
||||
s.ds_mut().unwrap().view.vw = 900.0;
|
||||
s.ds_mut().unwrap().view.vh = 500.0;
|
||||
let using = s.doc().unwrap().clone();
|
||||
|
||||
let r = go_to_main(&mut s, card);
|
||||
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(k.card), Some(1)), "{r}");
|
||||
let ds = s.ds().unwrap();
|
||||
assert_eq!(ds.path.as_deref(), Some(lib_path.as_str()));
|
||||
assert_eq!((ds.selection.clone(), ds.page), (vec![k.card], kit_page));
|
||||
assert_eq!((ds.view.vw, ds.view.vh), (900.0, 500.0), "the canvas it opens on is the one shown, so the fit stands");
|
||||
let shown = ds.doc.world_bounds(k.card).unwrap().center();
|
||||
assert_eq!((ds.view.cx, ds.view.cy), (shown.x, shown.y));
|
||||
|
||||
for (layer, main) in [(nested_button, k.button), (chip, chips[1])] {
|
||||
let r = go_to_main(&mut s, layer);
|
||||
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(main), Some(1)), "{r}");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![main]);
|
||||
}
|
||||
assert_eq!(s.docs.len(), 2, "the open library file comes to the front");
|
||||
assert_eq!(s.docs[0].doc.as_ref(), &using, "the file going there is unchanged");
|
||||
|
||||
s.active = 1;
|
||||
ex(&mut s, "edit.delete", json!({"ids": [k.card]}));
|
||||
let r = go_to_main(&mut s, card);
|
||||
assert!(r["id"].is_null() && r["index"] == 1, "{r}");
|
||||
assert!(s.message.take().is_some_and(|m| m.contains("isn't in this file any more")));
|
||||
|
||||
ex(&mut s, "file.close", json!({"index": 1, "force": true}));
|
||||
std::fs::rename(&lib_path, d.join("moved.figcraft")).unwrap();
|
||||
s.active = 0;
|
||||
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
|
||||
assert!(e.to_string().contains("isn't at"), "{e}");
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
|
||||
let NodeKind::Instance { component: copy, .. } = node(&s, card).kind else { panic!("not an instance") };
|
||||
s.doc_mut().unwrap().libraries_mut().components.retain(|r| r.root != copy);
|
||||
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
|
||||
assert!(e.to_string().contains("no link to its library"), "an imported copy without its record: {e}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_styles_apply_and_import_with_pages() {
|
||||
let d = dir("styles");
|
||||
|
||||
@@ -218,15 +218,16 @@ impl Session {
|
||||
// Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping.
|
||||
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl;
|
||||
let mut guides = Vec::new();
|
||||
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
|
||||
// Keep the box's origin on whole pixels (a rotated parent frame's, inside one);
|
||||
// snapping to layers comes after, so it wins on the axes it snaps.
|
||||
d = targets.round_move(d);
|
||||
}
|
||||
if snap_on {
|
||||
let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6));
|
||||
d += c;
|
||||
guides = g2;
|
||||
}
|
||||
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
|
||||
// Keep the box's origin on whole pixels (a rotated parent frame's, inside one).
|
||||
d = targets.round_move(d);
|
||||
}
|
||||
let ids = ids.clone();
|
||||
let dup = *duplicate;
|
||||
let page = self.page()?;
|
||||
@@ -475,20 +476,21 @@ impl Session {
|
||||
let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None };
|
||||
let mut p = ev.p();
|
||||
let mut guides = Vec::new();
|
||||
let _ = start;
|
||||
// In the handle box's own axes (one layer: its local space).
|
||||
let (frame, w0, h0) = r.frame;
|
||||
// The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool
|
||||
// scales by the factor as dragged). Snapping to layers comes after, so it wins on the axes
|
||||
// it snaps.
|
||||
if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) {
|
||||
p = Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
if let Some(t) = &targets {
|
||||
let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6));
|
||||
p.x += dx;
|
||||
p.y += dy;
|
||||
guides = g;
|
||||
}
|
||||
let _ = start;
|
||||
// In the handle box's own axes (one layer: its local space).
|
||||
let (frame, w0, h0) = r.frame;
|
||||
// The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool
|
||||
// scales by the factor as dragged).
|
||||
if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) {
|
||||
p = Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
let q = frame.inverse() * p;
|
||||
let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false);
|
||||
if scale_tool {
|
||||
|
||||
@@ -29,20 +29,23 @@ impl Session {
|
||||
let tool = self.tool.tool;
|
||||
let mut p = ev.p();
|
||||
let mut guides = Vec::new();
|
||||
let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
|
||||
// The pixel grid first: snapping to layers comes after, so it wins on the axes it snaps.
|
||||
if pixel {
|
||||
p = Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl {
|
||||
let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6));
|
||||
p.x += dx;
|
||||
p.y += dy;
|
||||
guides = g;
|
||||
}
|
||||
let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
|
||||
let mut created = id;
|
||||
let page = self.page()?;
|
||||
self.preview(|doc, sel| {
|
||||
let (mut a, mut b) = (start, p);
|
||||
let (mut a, b) = (start, p);
|
||||
if pixel {
|
||||
a = Point::new(a.x.round(), a.y.round());
|
||||
b = Point::new(b.x.round(), b.y.round());
|
||||
}
|
||||
let pw = doc.world_transform(parent);
|
||||
let (kind, base) = match tool {
|
||||
|
||||
@@ -186,28 +186,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| {
|
||||
let cmd = "component.goToMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
let doc = s.doc()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
|
||||
return Err(bad(cmd, "not an instance"));
|
||||
};
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
spec!(
|
||||
"component.goToMain",
|
||||
"Go to main component",
|
||||
"{id?} selects the instance's main component; a library component's main is in its library file, which opens in a tab (or comes to the front) with the main selected. Returns {id} (the main in the file now shown), and `index` (the tab) when it went to a library file",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "component.goToMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
let doc = s.doc()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
|
||||
return Err(bad(cmd, "not an instance"));
|
||||
};
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
}
|
||||
if let Some((library, key)) = crate::library::library_source(doc, component) {
|
||||
let name = crate::library::library_name(doc, library).to_string();
|
||||
let library = library.to_string();
|
||||
return go_to_library_main(s, &library, &name, key);
|
||||
}
|
||||
if doc.is_remote(component) {
|
||||
return Err(EngineError::Disabled(
|
||||
cmd.into(),
|
||||
"the main component is a library copy with no link to its library (it came with an imported file): enable that library to reach it"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![component])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(component) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": component}))
|
||||
}
|
||||
if let Some(lib) = crate::library::library_of(doc, component) {
|
||||
let name = crate::library::library_name(doc, lib);
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("the main component is in the library \"{name}\": open that file to edit it")));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![component])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(component) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": component}))
|
||||
}),
|
||||
),
|
||||
spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes),
|
||||
spec!(
|
||||
"component.restoreMain",
|
||||
@@ -283,6 +297,43 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result<Value> {
|
||||
let cmd = "component.goToMain";
|
||||
let refuse = |why: String| EngineError::Disabled(cmd.into(), why);
|
||||
let path = s
|
||||
.doc()?
|
||||
.libraries
|
||||
.link(library)
|
||||
.map(|l| l.path.clone())
|
||||
.ok_or_else(|| refuse(format!("the library \"{name}\" was removed from this file")))?;
|
||||
if crate::library_server::is_server_link(&path) {
|
||||
return Err(refuse(format!("the main component is in the team library \"{name}\": open its file from the server to edit it")));
|
||||
}
|
||||
if !craft_docpath::is_local(&path) {
|
||||
return Err(refuse(format!("{path}: a library is opened only from this computer")));
|
||||
}
|
||||
if !crate::file_access::exists(&path) {
|
||||
return Err(refuse(format!("the library file of \"{name}\" isn't at {path} any more")));
|
||||
}
|
||||
let viewport = s.ds().map(|d| (d.view.vw, d.view.vh))?;
|
||||
let opened = super::file::open(s, &json!({"path": path}))?;
|
||||
let ds = s.ds_mut()?;
|
||||
(ds.view.vw, ds.view.vh) = viewport;
|
||||
let doc = &ds.doc;
|
||||
let same_library = doc.published.as_ref().is_some_and(|p| p.manifest.id == library);
|
||||
let main = doc.node(key).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }));
|
||||
if !same_library || !main || doc.is_remote(key) {
|
||||
s.message = Some(format!("{name}: the main component isn't in this file any more"));
|
||||
return Ok(json!({"id": null, "index": opened["index"]}));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![key])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(key) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": key, "index": opened["index"]}))
|
||||
}
|
||||
|
||||
/// Place an instance of `component` centred on page point (`x`, `y`), in the frame or auto layout
|
||||
/// slot under it (`instance.place`).
|
||||
pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variable
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
use open::append_pages;
|
||||
use open::{import, open, open_recent, revert};
|
||||
pub(crate) use open::open;
|
||||
use open::{import, open_recent, revert};
|
||||
pub(crate) use save::save_ends_preview;
|
||||
pub use save::{FileSnapshot, file_bytes, thumbnail};
|
||||
use save::{color_profile, mark_saved, save, save_as};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"files": files}))
|
||||
}
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let variables = variables_param("file.open", p)?;
|
||||
if string(p, "path").is_none()
|
||||
&& let Some(d) = string(p, "data")
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -625,6 +625,33 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_to_layers_wins_over_the_pixel_grid() {
|
||||
// Both on (the defaults): a layer's centre at x = 5.5 is still a target.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 11.0, 11.0, None);
|
||||
let b = rect(&mut s, 200.0, 300.0, 100.0, 100.0, None);
|
||||
let _ = a;
|
||||
ex(&mut s, "select.set", json!({"ids": [b]}));
|
||||
// Moving: B's centre (grabbed) snaps to A's; the free axis stays on whole pixels.
|
||||
s.pointer(&ev(PointerKind::Down, Point::new(250.0, 350.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(6.0, 352.4), NONE)).unwrap();
|
||||
assert!(s.tool.overlay.guides.iter().any(|g| near(g.x0, 5.5) && near(g.x1, 5.5)), "{:?}", s.tool.overlay.guides);
|
||||
s.pointer(&ev(PointerKind::Up, Point::new(6.0, 352.4), NONE)).unwrap();
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(-44.5, 302.0), "moved onto A's centre");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
// Out of reach of any layer, a move lands on whole pixels.
|
||||
drag(&mut s, Point::new(250.0, 350.0), Point::new(260.3, 360.6), NONE);
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(210.0, 311.0));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
// Resizing: the left handle snaps to A's centre; the bottom one lands on whole pixels.
|
||||
drag(&mut s, Point::new(200.0, 350.0), Point::new(6.0, 350.0), NONE);
|
||||
assert!(near(bounds(&s, b).x0, 5.5), "{:?}", bounds(&s, b));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
drag(&mut s, Point::new(250.0, 400.0), Point::new(250.0, 450.6), NONE);
|
||||
assert_eq!(size(&s, b), (100.0, 151.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_cursors_stay_diagonal_whatever_the_proportions() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -129,9 +129,6 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
|
||||
Some(readable) => readable,
|
||||
None => with_pixel_size(encoded_clipboard_image(app)?)?,
|
||||
};
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let density = figcraft_format::pixel_density(&png);
|
||||
let (w, h) = figcraft_format::placed_size(w, h, density, figcraft_engine::cmd::create::PLACED_IMAGE_MAX_SIDE);
|
||||
Some((png, w, h))
|
||||
@@ -140,8 +137,14 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
|
||||
const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000;
|
||||
|
||||
fn with_pixel_size(png: Vec<u8>) -> Option<(Vec<u8>, (u32, u32))> {
|
||||
let size = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
Some((png, size))
|
||||
use image::ImageDecoder as _;
|
||||
let decoder = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
|
||||
return None;
|
||||
}
|
||||
image::DynamicImage::from_decoder(decoder).ok()?;
|
||||
Some((png, (w, h)))
|
||||
}
|
||||
|
||||
fn encoded_clipboard_image(app: &mut FigApp) -> Option<Vec<u8>> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -549,3 +549,18 @@ fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() {
|
||||
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
|
||||
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipboard_png_with_a_cut_off_body_falls_back_to_the_decoded_image() {
|
||||
let mut w = Win::new();
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let png = png.into_inner();
|
||||
let cut_off = png[..png.len() - 20].to_vec();
|
||||
assert_eq!(image::ImageReader::new(std::io::Cursor::new(&cut_off)).with_guessed_format().unwrap().into_dimensions().unwrap(), (4, 4));
|
||||
*w.clip.borrow_mut() = Clip { png: Some(cut_off), image: Some((2, 2, vec![255; 16])), ..Clip::default() };
|
||||
w.cmd_v();
|
||||
let doc = w.app.session.doc().unwrap();
|
||||
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
|
||||
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -50,6 +50,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|---|---|---|
|
||||
| Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files |
|
||||
| Enable a library in a file; read-only linked copies | done | |
|
||||
| Go to main component from an instance of a library component | done | opens the library file in a tab, or brings it to the front, on the page holding the main, selected and zoomed to; a variant goes to that variant. Team libraries on a server are refused with a note; no Return to instance yet |
|
||||
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
|
||||
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
|
||||
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart |
|
||||
@@ -78,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
|
||||
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
|
||||
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user