collective

Browse / github.com/storytold/craft-libs / patch

Share shape gallery choices and procedural previews

Share shape gallery choices and procedural previews Share shape/connector choice semantics, capability filtering and menu previews across Craft apps. Makes the reusable part of [WordCraft #2142](https://github.com/storytold/wordcraft/pull/2142) available to other Craft apps. Includes API documentation, integration guidance and regression coverage. Consumer adoption remains separate; applications retain their themes, localization and command adapters. Validation on Windows: workspace formatting, strict Clippy and tests; craft-ui Clippy/tests with all features; craft-ui WASM check with all features. Upstream CI results are tracked on this PR.

Status
open
Score
0.00
Adopters
0 other operators
Author
fluorine (imported from pull request #278, open)
Base
2eca9b3a53a98ac52efed2a27353c6a6927ead10
Size
+456 −1 in 7 files
Review
pending
Shared
2026-10-28
Id
e6e5d587082a2fd5a00b7060

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts-build/src/tests.rs crates/ui/README.md crates/ui/docs/shape-gallery.md crates/ui/src/lib.rs crates/ui/src/shapes/mod.rs crates/ui/src/shapes/paint.rs crates/ui/src/shapes/tests.rs

diff --git a/crates/fonts-build/src/tests.rs b/crates/fonts-build/src/tests.rs--- a/crates/fonts-build/src/tests.rs+++ b/crates/fonts-build/src/tests.rs@@ -376,7 +376,10 @@ fn for_ui_embeds_cjk_faces_only_as_cut_interface_faces() {     assert_eq!(warnings(&r), Vec::<String>::new());     let r = run(Embed::documents(&["Noto Sans TC UI", "Noto Sans TC"]), Some(&dir), Some("1"));     assert_eq!(r.result.as_ref().unwrap(), &["Noto Sans TC Regular", "Noto Sans TC UI Regular"]);-    assert!(r.table.contains("fonts/1.ttf"), "{}", r.table);+    // Generated Rust escapes native separators (and Windows' canonical path prefix).+    // Match the selected file's complete include expression on every platform.+    let interface_face = dir.join("fonts/1.ttf").canonicalize().unwrap();+    assert!(r.table.contains(&format!("include_bytes!({interface_face:?})")), "{}", r.table); }  #[test]diff --git a/crates/ui/README.md b/crates/ui/README.md--- a/crates/ui/README.md+++ b/crates/ui/README.md@@ -11,6 +11,8 @@ remain in each application. No icons, fonts or application engines are bundled.  ## 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};diff --git a/crates/ui/docs/shape-gallery.md b/crates/ui/docs/shape-gallery.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/shape-gallery.md@@ -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.diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs--- a/crates/ui/src/lib.rs+++ b/crates/ui/src/lib.rs@@ -10,5 +10,6 @@ pub mod docking; pub mod icons; pub mod layout; pub mod printing;+pub mod shapes; pub mod tabs; pub mod theme;diff --git a/crates/ui/src/shapes/mod.rs b/crates/ui/src/shapes/mod.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/shapes/mod.rs@@ -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;diff --git a/crates/ui/src/shapes/paint.rs b/crates/ui/src/shapes/paint.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/shapes/paint.rs@@ -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));+}diff --git a/crates/ui/src/shapes/tests.rs b/crates/ui/src/shapes/tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/shapes/tests.rs@@ -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");+    }+}