fix(ui): use vector path icon for shape layers in Properties (#10787)

* fix(ui): use vector path icon for shape layers in Properties

* test(ui): verify rendered shape Properties icon size

* test(ui): verify rendered shape icon identity

* test(ui): verify painted icons for all layer kinds

* test(ui): pin expected Properties icons for all layer kinds

* fix(ui): align shape icon size and document attribution

* ci: rerun checks

* Enhance tests for shape icon transparency

Added assertion to check for nontransparent pixels in shape icons.

* Enhance tests for shape icon rasterization

Add tests for shape icon rasterization and anchor visibility.

* Add tests for shape properties and header icons

Added tests for shape properties and header icons.

* Fix attribution for Lucide icons in ATTRIBUTION.md
This commit is contained in:
Gabriel Heuser authored and GitHub committed 2026-11-11 12:04:48 -05:00
1 parent d0299c4179
commit 0143b14844
5 files changed
+103 -2

No files matched your search

+2 -1
View File
@@ -15,8 +15,9 @@ next to them.
| `epaint_default_fonts::EGUI_ICONS` (build input; deflated into `OUT_DIR/egui_fonts/egui-icons.ttf.deflate`, registered by `crates/ui-egui/src/text/egui_fonts.rs`) | egui-icons, five-glyph subset of emoji-icon-font | John Slegers | [egui fork at 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [original](https://github.com/jslegers/emoji-icon-font); egui subset, unmodified here | MIT; Copyright (c) 2014 John Slegers; full upstream notice in [NOTICE](NOTICE) |
| `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | <https://github.com/rsms/inter> | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) |
| `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | <https://github.com/JetBrains/JetBrainsMono> | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) |
| `assets/icons/*.svg` except `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg`, `spirit-level.svg` (116 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/*.svg` except `vector-shape.svg`, `slice-knife.svg`, `slice-select.svg`, `font-similar.svg`, `font-variable.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `text-warp.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `history-brush.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg`, `align-edge-*.svg` and `distribute-*.svg`, `crop-overlay-*.svg`, `vertical-align-*.svg`, `spirit-level.svg` (116 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | <https://github.com/lucide-icons/lucide> (`icons/<name>.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | <https://github.com/feathericons/feather> via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) |
| `assets/icons/vector-shape.svg` | Vector path shape-layer glyph with four anchor points | @superplay2021 | Original work for PhotoCraft | MIT OR Apache-2.0 |
| `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/slice-select.svg` | Slice Select tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
| `assets/icons/crop-overlay-*.svg` | Crop overlay glyphs: Rule of Thirds, Grid, Diagonal, Triangle, Golden Ratio and Golden Spiral inside the crop-mark frame | PhotoCraft contributors | Original work | MIT OR Apache-2.0 |
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path d="M8 6H16 M18 8V16 M16 18H8 M6 16V8"/>
<rect x="4" y="4" width="4" height="4"/>
<rect x="16" y="4" width="4" height="4"/>
<rect x="4" y="16" width="4" height="4"/>
<rect x="16" y="16" width="4" height="4"/>
</svg>

After

Width:  |  Height:  |  Size: 368 B

+1
View File
@@ -184,6 +184,7 @@ pub static ICONS: &[(&str, &[u8])] = &[
("type-mask-vertical", include_bytes!("../../../assets/icons/type-mask-vertical.svg")),
("undo-2", include_bytes!("../../../assets/icons/undo-2.svg")),
("unlink", include_bytes!("../../../assets/icons/unlink.svg")),
("vector-shape", include_bytes!("../../../assets/icons/vector-shape.svg")),
("vertical-align-bottom", include_bytes!("../../../assets/icons/vertical-align-bottom.svg")),
("vertical-align-middle", include_bytes!("../../../assets/icons/vertical-align-middle.svg")),
("vertical-align-top", include_bytes!("../../../assets/icons/vertical-align-top.svg")),
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn kind_icon(content: &LayerContent) -> &'static str {
LayerContent::Group(_) => "folder",
LayerContent::Fill(_) => "paint-bucket",
LayerContent::Text(_) => "type",
LayerContent::Shape(_) => "pentagon",
LayerContent::Shape(_) => "vector-shape",
LayerContent::Smart(_) => "app-window",
LayerContent::Raster(_) => "image",
}
@@ -363,3 +363,95 @@ fn background_in_a_multiple_selection_keeps_align_and_distribute() {
assert_eq!((b.x0 + b.x1) / 2, 150, "centred on the Background, which spans the canvas: {b:?}");
assert_eq!(b.y0, 30, "only horizontally");
}
/// Shape layers use the registered vector icon; raster and text mappings stay unchanged.
#[test]
fn shape_properties_uses_vector_shape_icon() {
let (session, layers) = kinds();
let app = PhotocraftApp::new(session, crate::Services::default());
assert!(crate::icons::exists("vector-shape"));
for (kind, id) in layers {
let layer = layer_of(&app, id);
let icon = kind_icon(&layer.content);
let expected_icon = match kind {
"pixel" => "image",
"shape" => "vector-shape",
"type" => "type",
_ => panic!("Unexpected layer kind: {kind}"),
};
assert_eq!(icon, expected_icon, "{kind}: Properties icon");
}
}
/// Headers paint the expected 15 px icons, and the shape glyph has four hollow anchors.
#[test]
fn shape_header_paints_correct_icon() {
let (session, layers) = kinds();
let app = PhotocraftApp::new(session, crate::Services::default());
for (kind, id) in layers {
let layer = layer_of(&app, id);
let icon = kind_icon(&layer.content);
let mut ready = false;
let mut h = Harness::new_ui(move |ui| {
if !ready {
crate::theme::install_fonts(ui.ctx());
egui_extras::install_image_loaders(ui.ctx());
ready = true;
return;
}
header(ui, &layer);
});
h.run_steps(4);
let icon_sizes: Vec<_> = h
.output()
.shapes
.iter()
.filter_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) if rect.brush.is_some() => Some(rect.rect.size()),
_ => None,
})
.collect();
let expected = 15.0;
assert_eq!(icon_sizes, vec![vec2(expected, expected)], "{kind}: Properties header icon size");
let expected_texture = h
.ctx
.try_load_texture(
&format!("bytes://icons/{icon}.svg"),
Default::default(),
egui::load::SizeHint::Size { width: expected as u32, height: expected as u32, maintain_aspect_ratio: false },
)
.unwrap()
.texture_id()
.expect("icon texture loaded");
let painted_texture = h.output().shapes.iter().find_map(|shape| match &shape.shape {
egui::Shape::Rect(rect) => rect.brush.as_ref().map(|brush| brush.fill_texture_id),
_ => None,
});
assert_eq!(painted_texture, Some(expected_texture), "{kind}: Properties header must paint {icon}");
if kind == "shape" {
let image = crate::icons::rasterize_svg(icon, 15, 15).expect("shape icon rasterized");
assert!(image.pixels.iter().any(|pixel| pixel.a() > 0), "shape icon must have nontransparent pixels");
// Native SVG dimensions keep anchor centres clear of antialiased edges.
let anchors = crate::icons::rasterize_svg(icon, 24, 24).expect("shape anchors rasterized");
for (x, y) in [(6, 6), (18, 6), (6, 18), (18, 18)] {
assert_eq!(anchors[(x, y)].a(), 0, "anchor at ({x}, {y}) must be hollow");
for (edge_x, edge_y) in [(x - 2, y), (x + 2, y), (x, y - 2), (x, y + 2)] {
assert!(anchors[(edge_x, edge_y)].a() > 0, "anchor at ({x}, {y}) must have four visible sides");
}
}
}
}
}