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
@@ -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]@@ -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};new file mode 100644@@ -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.@@ -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;new file mode 100644@@ -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;new file mode 100644@@ -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));+}new file mode 100644@@ -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");+ }+}