Share shape gallery choices and procedural previews

* Share shape gallery choices and procedural previews

* Clip shape previews to their supplied rectangles

* Discard texture updates explicitly in headless rendering tests

* Honor fill colors across complete star and heart contours

* Make heart contour collection type explicit

* Use fixed-size triangle chunks in fill regression

---------

Co-authored-by: Fluorine <joseph.anglada92@gmail.com>
Co-authored-by: Joseph Anglada Muñiz <janglada@nd-soft.com>
This commit is contained in:
authored and GitHub committed 2026-11-12 10:37:36 -05:00
1 parent e56b2f1220
commit 6026ad570f
6 files changed
+452

No files matched your search

+2
View File
@@ -97,6 +97,8 @@ is separate; application-specific dialog behavior remains in each app.
## SVG catalogs
For common Insert Shapes choices and previews, see [the shared shape gallery](docs/shape-gallery.md).
```rust
use std::sync::OnceLock;
use craft_ui::icons::{SvgIconSet, SvgStyle};
+35
View File
@@ -0,0 +1,35 @@
# Shared Insert Shapes choices and previews
`craft_ui::shapes` shares the shape/connector menu and original procedural previews contributed in
[WordCraft #2142](https://github.com/storytold/wordcraft/pull/2142). It provides semantic preset
identities, default ordering, connector/arrow classification, capability filtering and a menu built
on the existing accessible `IconButton`. This is reusable menu logic as well as painting.
The overlap is concrete: GridCraft's [Insert ribbon](https://github.com/storytold/gridcraft/blob/b8771d51fb4da60bd48c13cf313d6d6014dc5e66/crates/ui-egui/src/ribbon.rs#L1534)
offers rectangle, rounded rectangle, ellipse, triangle, line, arrow and text box. WordCraft also
offers diamond, star, heart and connectors. Each host must filter by what its engine supports;
a preview does not imply import/export or rendering support in that app.
```rust
let chosen = craft_ui::shapes::menu(ui, 220.0, font, &button_style,
|preset| supported_by_this_app(preset),
|preset| localize(preset.source_label()),
);
if let Some(preset) = chosen {
// Adapt the semantic choice to this app's insertion operation.
}
```
`ShapePreset::key()` is a stable choice key, not a universal command payload.
The enum is non-exhaustive: capability adapters should reject unknown future presets by default.
WordCraft maps `StraightArrowConnector` to `kind: "straightConnector", arrow: "end"`; GridCraft
must hide it until its own engine supports attachment/routing. An ordinary `Arrow` remains a
different semantic choice. The library does not silently substitute unsupported objects.
Apps own localization, theme, popup lifetime, current document, insertion coordinates, undo and
selection. Additional actions such as New Drawing Canvas remain app-specific menu entries.
`paint_preset` is also available for app-owned gallery layouts. The previews use original path
geometry and no external assets. They are UI icons, not editable document geometry or a shape
rendering engine. Star and heart contours honor `fill` across their full area, including with no
outline; their fixed concave paths use triangle fans around verified interior centers.
Consumer adoption and command adapters are separate work.
+1
View File
@@ -18,6 +18,7 @@ pub mod layout;
pub mod localized;
pub mod panes;
pub mod printing;
pub mod shapes;
pub mod tabs;
#[cfg(all(feature = "screen-testing", not(target_arch = "wasm32")))]
pub mod testing;
+174
View File
@@ -0,0 +1,174 @@
//! Shared shape-choice semantics and procedural previews, without document insertion commands.
use crate::buttons::{IconButton, IconButtonStyle};
use egui::{Align2, FontId, Ui, Vec2};
mod paint;
pub use paint::paint_preset;
/// A menu choice, not a document shape model. Connector routing/attachment and text editing stay
/// in each app's engine. Stable keys are suitable for IDs and explicit adapters, not localized text.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ShapePreset {
Rectangle,
RoundedRectangle,
Ellipse,
Triangle,
Diamond,
Line,
Arrow,
Star,
Heart,
TextBox,
StraightConnector,
StraightArrowConnector,
ElbowConnector,
ElbowArrowConnector,
CurvedConnector,
CurvedArrowConnector,
}
/// Default presentation order. Filter by actual application capabilities before showing choices;
/// having a preview here never means an application's importer/renderer supports the shape.
pub const PRESETS: &[ShapePreset] = &[
ShapePreset::Rectangle,
ShapePreset::RoundedRectangle,
ShapePreset::Ellipse,
ShapePreset::Triangle,
ShapePreset::Diamond,
ShapePreset::Line,
ShapePreset::Arrow,
ShapePreset::Star,
ShapePreset::Heart,
ShapePreset::TextBox,
ShapePreset::StraightConnector,
ShapePreset::StraightArrowConnector,
ShapePreset::ElbowConnector,
ShapePreset::ElbowArrowConnector,
ShapePreset::CurvedConnector,
ShapePreset::CurvedArrowConnector,
];
impl ShapePreset {
/// Stable semantic choice key, independent of labels and any application's command protocol.
pub const fn key(self) -> &'static str {
match self {
Self::Rectangle => "rectangle",
Self::RoundedRectangle => "roundedRectangle",
Self::Ellipse => "ellipse",
Self::Triangle => "triangle",
Self::Diamond => "diamond",
Self::Line => "line",
Self::Arrow => "arrow",
Self::Star => "star",
Self::Heart => "heart",
Self::TextBox => "textBox",
Self::StraightConnector => "straightConnector",
Self::StraightArrowConnector => "straightArrowConnector",
Self::ElbowConnector => "elbowConnector",
Self::ElbowArrowConnector => "elbowArrowConnector",
Self::CurvedConnector => "curvedConnector",
Self::CurvedArrowConnector => "curvedArrowConnector",
}
}
/// English source string for the application's localization catalog. The menu always asks
/// the caller for a label; it never installs a translation service or uses a label as identity.
pub const fn source_label(self) -> &'static str {
match self {
Self::Rectangle => "Rectangle",
Self::RoundedRectangle => "Rounded Rectangle",
Self::Ellipse => "Oval",
Self::Triangle => "Triangle",
Self::Diamond => "Diamond",
Self::Line => "Line",
Self::Arrow => "Arrow",
Self::Star => "Star",
Self::Heart => "Heart",
Self::TextBox => "Text Box",
Self::StraightConnector => "Straight Connector",
Self::StraightArrowConnector => "Straight Arrow Connector",
Self::ElbowConnector => "Elbow Connector",
Self::ElbowArrowConnector => "Elbow Arrow Connector",
Self::CurvedConnector => "Curved Connector",
Self::CurvedArrowConnector => "Curved Arrow Connector",
}
}
pub const fn is_connector(self) -> bool {
matches!(
self,
Self::StraightConnector
| Self::StraightArrowConnector
| Self::ElbowConnector
| Self::ElbowArrowConnector
| Self::CurvedConnector
| Self::CurvedArrowConnector
)
}
/// Whether this choice has an arrow at its end. An ordinary arrow remains a different
/// choice from a connector with an arrow; the application decides its attachment semantics.
pub const fn has_end_arrow(self) -> bool {
matches!(self, Self::Arrow | Self::StraightArrowConnector | Self::ElbowArrowConnector | Self::CurvedArrowConnector)
}
}
/// Show the default shape list, returning the chosen semantic preset without executing commands.
///
/// `supported` hides capabilities absent from the host. `label` supplies localized accessible
/// text; IDs remain stable even for duplicate translations. The caller owns the popup, theme,
/// font, optional canvas/gallery actions and command mapping. Wrap repeated menus in `push_id`.
/// Uses the existing [`IconButton`] for keyboard/pointer/accessibility and disabled-state behavior.
/// Rows clip long labels; widths must be finite, positive and at most 4096 points or no rows show.
pub fn menu(
ui: &mut Ui,
width: f32,
font: FontId,
style: &IconButtonStyle,
mut supported: impl FnMut(ShapePreset) -> bool,
mut label: impl FnMut(ShapePreset) -> String,
) -> Option<ShapePreset> {
if !width.is_finite() || width <= 0.0 || width > 4096.0 {
return None;
}
let font = FontId::new(if font.size.is_finite() { font.size.clamp(1.0, 128.0) } else { 12.0 }, font.family);
let height = (font.size + 10.0).max(26.0);
let mut chosen = None;
let mut previous_connector = false;
let mut have_item = false;
for &preset in PRESETS {
if !supported(preset) {
continue;
}
if have_item && preset.is_connector() && !previous_connector {
ui.separator();
}
previous_connector = preset.is_connector();
have_item = true;
let label = label(preset);
ui.push_id(preset.key(), |ui| {
let response = IconButton::new(&label, Vec2::new(width, height), style).show(ui, |ui, rect, tint| {
let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(rect));
let icon = egui::Rect::from_center_size(egui::pos2(rect.left() + 14.0, rect.center().y), Vec2::splat(20.0));
paint_preset(&painter, icon, preset, egui::Stroke::new(1.4, tint), tint.gamma_multiply(0.15));
let text_rect = egui::Rect::from_min_max(egui::pos2((rect.left() + 30.0).min(rect.right()), rect.top()), rect.max);
painter.with_clip_rect(ui.clip_rect().intersect(text_rect)).text(
text_rect.left_center(),
Align2::LEFT_CENTER,
&label,
font.clone(),
tint,
);
});
if response.clicked() {
chosen = Some(preset);
}
});
}
chosen
}
#[cfg(test)]
mod tests;
+126
View File
@@ -0,0 +1,126 @@
use super::ShapePreset;
use egui::{Color32, Painter, Pos2, Rect, Shape, Stroke, StrokeKind, vec2};
/// Paint an original procedural preview, adapted from WordCraft's Insert Shapes menu (#2142).
/// Preview geometry is independent of document geometry: it does not insert/render editable
/// document objects. No external icon assets, fonts or application dependencies are needed.
/// Invalid/empty/excessive rectangles produce no paint; stroke widths are bounded to the icon.
pub fn paint_preset(painter: &Painter, rect: Rect, preset: ShapePreset, stroke: Stroke, fill: Color32) {
if !rect.is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 || rect.size().max_elem() > 4096.0 {
return;
}
let painter = painter.with_clip_rect(painter.clip_rect().intersect(rect));
let scale = rect.width().min(rect.height()) / 20.0;
let origin = rect.center() - vec2(10.0, 10.0) * scale;
let point = |x: f32, y: f32| origin + vec2(x, y) * scale;
let width = if stroke.width.is_finite() { stroke.width.clamp(0.0, rect.width().min(rect.height()) * 0.25) } else { 0.0 };
let stroke = Stroke::new(width, stroke.color);
let polygon = |points: &[(f32, f32)]| {
painter.add(Shape::convex_polygon(points.iter().map(|&(x, y)| point(x, y)).collect(), fill, stroke));
};
let line = |points: &[(f32, f32)]| {
painter.add(Shape::line(points.iter().map(|&(x, y)| point(x, y)).collect(), stroke));
};
let head = |tip: Pos2, previous: Pos2| {
let direction = (tip - previous).normalized();
let side = vec2(-direction.y, direction.x);
painter.add(Shape::line(
vec![tip - direction * (4.0 * scale) + side * (2.5 * scale), tip, tip - direction * (4.0 * scale) - side * (2.5 * scale)],
stroke,
));
};
match preset {
ShapePreset::Rectangle | ShapePreset::RoundedRectangle | ShapePreset::TextBox => {
let shape = Rect::from_min_max(point(3.0, 5.0), point(17.0, 15.0));
let radius = if preset == ShapePreset::RoundedRectangle { 3.0 * scale } else { 0.0 };
painter.rect(shape, radius, fill, stroke, StrokeKind::Inside);
if preset == ShapePreset::TextBox {
for y in [8.0, 10.0, 12.0] {
line(&[(6.0, y), (14.0, y)]);
}
}
}
ShapePreset::Ellipse => {
painter.add(Shape::ellipse_filled(rect.center(), vec2(7.5, 5.5) * scale, fill));
painter.add(Shape::ellipse_stroke(rect.center(), vec2(7.5, 5.5) * scale, stroke));
}
ShapePreset::Triangle => polygon(&[(10.0, 3.5), (17.0, 16.5), (3.0, 16.5)]),
ShapePreset::Diamond => polygon(&[(10.0, 3.0), (17.0, 10.0), (10.0, 17.0), (3.0, 10.0)]),
ShapePreset::Line | ShapePreset::Arrow => {
line(&[(4.0, 16.0), (16.0, 4.0)]);
if preset.has_end_arrow() {
head(point(16.0, 4.0), point(4.0, 16.0));
}
}
ShapePreset::Star => {
let points: Vec<_> = (0..10)
.map(|i| {
let angle = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
let radius = if i % 2 == 0 { 8.0 } else { 3.3 };
point(10.0 + radius * angle.cos(), 10.8 + radius * angle.sin())
})
.collect();
fill_contour(&painter, point(10.0, 10.8), &points, fill);
painter.add(Shape::closed_line(points, stroke));
}
ShapePreset::Heart => {
let points: Vec<_> = (0..32)
.map(|i| {
let t = i as f32 / 32.0 * std::f32::consts::TAU;
point(
10.0 + 16.0 * t.sin().powi(3) * 0.44,
9.6 - (13.0 * t.cos() - 5.0 * (2.0 * t).cos() - 2.0 * (3.0 * t).cos() - (4.0 * t).cos()) * 0.44,
)
})
.collect();
fill_contour(&painter, point(10.0, 10.0), &points, fill);
painter.add(Shape::closed_line(points, stroke));
}
_ => {
let points: Vec<_> = match preset {
ShapePreset::ElbowConnector | ShapePreset::ElbowArrowConnector => {
vec![point(4.0, 15.0), point(10.0, 15.0), point(10.0, 5.0), point(16.0, 5.0)]
}
ShapePreset::CurvedConnector | ShapePreset::CurvedArrowConnector => (0..=16)
.map(|i| {
let t = i as f32 / 16.0;
let m = 1.0 - t;
let w = [m * m * m, 3.0 * m * m * t, 3.0 * m * t * t, t * t * t];
point(w[0] * 4.0 + w[1] * 12.0 + w[2] * 8.0 + w[3] * 16.0, w[0] * 15.0 + w[1] * 15.0 + w[2] * 5.0 + w[3] * 5.0)
})
.collect(),
_ => vec![point(4.0, 15.0), point(16.0, 5.0)],
};
painter.add(Shape::line(points.clone(), stroke));
if let Some(&first) = points.first() {
painter.circle_filled(first, 1.6 * scale, stroke.color);
}
if let [.., previous, tip] = points.as_slice() {
if preset.has_end_arrow() {
head(*tip, *previous);
} else {
painter.circle_filled(*tip, 1.6 * scale, stroke.color);
}
}
}
}
// The painter's existing clip handles its host; all preview geometry stays in the icon box.
}
/// These two fixed contours are star-shaped around the supplied center: it lies in every
/// edge's interior half-plane. A triangle fan therefore fills their concavities without the
/// convex-polygon assumption. This is not a triangulator for arbitrary application paths.
fn fill_contour(painter: &Painter, center: Pos2, points: &[Pos2], fill: Color32) {
if points.len() < 3 || fill == Color32::TRANSPARENT {
return;
}
let mut mesh = egui::Mesh::default();
mesh.colored_vertex(center, fill);
for &point in points {
mesh.colored_vertex(point, fill);
}
for index in 0..points.len() {
mesh.add_triangle(0, 1 + index as u32, 1 + ((index + 1) % points.len()) as u32);
}
painter.add(Shape::mesh(mesh));
}
+114
View File
@@ -0,0 +1,114 @@
use super::*;
use crate::buttons::ButtonVisuals;
use egui::{Color32, CornerRadius, Stroke};
use egui_kittest::kittest::Queryable;
fn style() -> IconButtonStyle {
let normal = ButtonVisuals { fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: Color32::BLACK };
IconButtonStyle {
normal,
hovered: ButtonVisuals { fill: Color32::LIGHT_BLUE, ..normal },
pressed: None,
selected: normal,
corner_radius: CornerRadius::same(3),
focus_stroke: Stroke::new(1.0, Color32::BLUE),
}
}
#[test]
fn capability_filtered_menu_uses_stable_identity_for_duplicate_localized_labels() {
let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 300.0)).build_ui_state(
|ui, chosen: &mut Option<ShapePreset>| {
if let Some(preset) = menu(
ui,
180.0,
FontId::proportional(12.0),
&style(),
|p| matches!(p, ShapePreset::Rectangle | ShapePreset::Ellipse),
|_| "Object".into(),
) {
*chosen = Some(preset);
}
},
None,
);
h.run_steps(2);
let items: Vec<_> = h.get_all_by_label("Object").collect();
assert_eq!(items.len(), 2);
items[1].click();
h.run_steps(2);
assert_eq!(*h.state(), Some(ShapePreset::Ellipse));
}
#[test]
fn catalog_keys_are_unique_and_arrow_connector_semantics_remain_distinct() {
let keys: std::collections::HashSet<_> = PRESETS.iter().map(|p| p.key()).collect();
assert_eq!(keys.len(), PRESETS.len());
assert!(ShapePreset::StraightArrowConnector.is_connector());
assert!(ShapePreset::StraightArrowConnector.has_end_arrow());
assert!(!ShapePreset::Arrow.is_connector());
assert!(ShapePreset::Arrow.has_end_arrow());
assert!(!ShapePreset::ElbowConnector.has_end_arrow());
}
#[test]
fn every_procedural_preview_tessellates_and_invalid_geometry_is_inert() {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
for &preset in PRESETS {
let rect = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::splat(20.0));
paint_preset(ui.painter(), rect, preset, Stroke::new(f32::NAN, Color32::BLACK), Color32::LIGHT_BLUE);
}
});
out.textures_delta.clear();
for primitive in ctx.tessellate(out.shapes, out.pixels_per_point) {
assert!(primitive.clip_rect.min.x >= 10.0 && primitive.clip_rect.min.y >= 10.0);
assert!(primitive.clip_rect.max.x <= 30.0 && primitive.clip_rect.max.y <= 30.0);
if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive {
assert!(mesh.is_valid());
assert!(mesh.vertices.iter().all(|v| v.pos.is_finite()));
}
}
let out = ctx.run_ui(egui::RawInput::default(), |ui| {
for &preset in PRESETS {
paint_preset(ui.painter(), egui::Rect::NAN, preset, Stroke::NONE, Color32::WHITE);
}
});
assert!(out.shapes.is_empty() || out.shapes.iter().all(|s| matches!(s.shape, egui::Shape::Noop)));
out.drop_without_applying_deltas();
}
#[test]
fn concave_previews_fill_the_star_tips_and_heart_without_an_outline() {
let cross = |a: egui::Pos2, b: egui::Pos2, c: egui::Pos2| {
let ab = b - a;
let ac = c - a;
ab.x * ac.y - ab.y * ac.x
};
for (preset, sample) in [(ShapePreset::Star, egui::pos2(20.0, 14.0)), (ShapePreset::Heart, egui::pos2(20.0, 20.0))] {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
paint_preset(ui.painter(), egui::Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::splat(20.0)), preset, Stroke::NONE, Color32::RED);
});
out.textures_delta.clear();
let mut covered = false;
for primitive in ctx.tessellate(out.shapes, out.pixels_per_point) {
if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive {
assert!(mesh.is_valid());
for triangle in mesh.indices.as_chunks::<3>().0 {
let [a, b, c] = [mesh.vertices[triangle[0] as usize], mesh.vertices[triangle[1] as usize], mesh.vertices[triangle[2] as usize]];
// Consistent winding verifies each fixed contour's center stays in its kernel.
assert!(cross(a.pos, b.pos, c.pos) > 0.0);
covered |= primitive.clip_rect.contains(sample)
&& a.color == Color32::RED
&& b.color == Color32::RED
&& c.color == Color32::RED
&& cross(a.pos, b.pos, sample) >= -0.0001
&& cross(b.pos, c.pos, sample) >= -0.0001
&& cross(c.pos, a.pos, sample) >= -0.0001;
}
}
}
assert!(covered, "{preset:?} must honor its fill with no outline");
}
}