From 6026ad570f3e1b759d39c7553a241472728b4044 Mon Sep 17 00:00:00 2001 From: Joseph Anglada Date: Thu, 12 Nov 2026 11:37:36 -0400 Subject: [PATCH] Share shape gallery choices and procedural previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Joseph Anglada Muñiz --- crates/ui/README.md | 2 + crates/ui/docs/shape-gallery.md | 35 +++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/shapes/mod.rs | 174 ++++++++++++++++++++++++++++++++ crates/ui/src/shapes/paint.rs | 126 +++++++++++++++++++++++ crates/ui/src/shapes/tests.rs | 114 +++++++++++++++++++++ 6 files changed, 452 insertions(+) create mode 100644 crates/ui/docs/shape-gallery.md create mode 100644 crates/ui/src/shapes/mod.rs create mode 100644 crates/ui/src/shapes/paint.rs create mode 100644 crates/ui/src/shapes/tests.rs diff --git a/crates/ui/README.md b/crates/ui/README.md index 4bd8b8106..f2ae27b0f 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -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}; diff --git a/crates/ui/docs/shape-gallery.md b/crates/ui/docs/shape-gallery.md new file mode 100644 index 000000000..6221de8d8 --- /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 index 3c98f5f0c..4c78d0260 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -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; diff --git a/crates/ui/src/shapes/mod.rs b/crates/ui/src/shapes/mod.rs new file mode 100644 index 000000000..fcf809746 --- /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 { + 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.rs new file mode 100644 index 000000000..ef0172d65 --- /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.rs new file mode 100644 index 000000000..32d89e426 --- /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| { + 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"); + } +}