diff --git a/Cargo.lock b/Cargo.lock index 79fb56b..66255b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -107,7 +107,7 @@ dependencies = [ ] [[package]] -name = "craft-ui-icons" +name = "craft-ui" version = "0.1.0" dependencies = [ "egui", diff --git a/Cargo.toml b/Cargo.toml index c899b30..92e3a52 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/storytold/craft-libs" [workspace.dependencies] # internal: add each crate here as `craft- = { path = "crates/" }` -craft-ui-icons = { path = "crates/ui-icons" } +craft-ui = { path = "crates/ui" } egui = { version = "0.36", default-features = false } [workspace.lints.rust] diff --git a/NOTICE b/NOTICE index c7beb64..ae61672 100644 --- a/NOTICE +++ b/NOTICE @@ -12,11 +12,15 @@ None: every bundled asset is original to craft-libs. Source provenance -The SVG icon preparation in crates/ui-icons is adapted from: +The SVG icon preparation in crates/ui is adapted from: - PhotoCraft, crates/ui-egui/src/icons.rs, revision e7a83ca342cdff568254f85f442b9cc8afb08066. https://github.com/storytold/photocraft Copyright (c) 2026 ArtCraft Team and the PhotoCraft contributors. - VectorCraft, crates/ui-egui/src/icons.rs, revision 825d4fdb8b527c597855d273fb4bcc1971855b6a. https://github.com/storytold/vectorcraft Copyright (c) 2026 ArtCraft Team and the VectorCraft contributors. -Both sources are licensed MIT OR Apache-2.0. No icon artwork is copied here. +The icon-button interaction/style boundary is adapted from PhotoCraft (above) and: +- PdfCraft, crates/ui-egui/src/icons.rs, revision f626db25cca2c89f8fa3f7e30d22bf59faea65ec. + https://github.com/storytold/pdfcraft + Copyright (c) 2026 ArtCraft Team and the PdfCraft contributors. +All three sources are licensed MIT OR Apache-2.0. No icon artwork is copied here. diff --git a/README.md b/README.md index 51e6588..edde669 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " &'static SvgIconSet { - static ICONS_SET: OnceLock = OnceLock::new(); - ICONS_SET.get_or_init(|| SvgIconSet::new( - ICONS, - SvgStyle { current_color: "#ffffff", stroke_width: Some("1.75") }, - "bytes://my-app/icons/", - "square", - )) -} - -fn icon(name: &str) -> egui::Image<'static> { - egui::Image::new(icons().source(name)) -} -``` - -The application installs `egui_extras::install_image_loaders` with its `svg` feature before drawing. -It sets tint and size on the returned image and retains its existing interaction/tooltip code. -The library does not install loaders or an egui theme. - -The catalog is immutable. SVGs are prepared once and held in `Arc<[u8]>`; lookups share those bytes. -The fallback uses the requested name in its URI, matching PhotoCraft and VectorCraft. Missing both -the requested name and fallback produces empty bytes for the loader to report as an error, without -indexed lookups or a panic. `contains` and `canonical_name` inspect only actual catalog entries. - -Use a different URI prefix for every catalog/style combination used in the same egui context. -egui caches by URI; supplying different bytes under one URI would reuse stale content. Apps with -their own contexts can retain their current prefixes. Unknown names do not add entries to this -catalog, though the egui loader may cache a requested URI. - -This is an adapter for trusted, compiled-in monochrome assets, not an SVG sanitizer. It performs -literal `currentColor` and optional `stroke-width="2"` substitutions, keeps other widths and explicit -colors untouched, and uses the existing lossy UTF-8 behavior. General SVG parsing and rasterization -remain the responsibility of the existing loader. - -The crate uses egui 0.36 and Rust 1.95 or later. Its production dependency disables egui's default -features so fonts and rendering features remain the application's choice. See the -[suite audit](../../docs/ui-components-audit.md) for candidates, policies and adoption order. - -Validation: - -```sh -cargo fmt --check -cargo clippy --workspace --all-targets -- -D warnings -cargo test --workspace -cargo check --target wasm32-unknown-unknown -p craft-ui-icons -``` diff --git a/crates/ui-icons/Cargo.toml b/crates/ui/Cargo.toml similarity index 76% rename from crates/ui-icons/Cargo.toml rename to crates/ui/Cargo.toml index db031b8..3f777fb 100644 --- a/crates/ui-icons/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "craft-ui-icons" +name = "craft-ui" version.workspace = true edition.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true -description = "Prepared SVG icon catalogs for Craft egui frontends" +description = "Reusable egui controls and icon catalogs for Craft frontends" [lints] workspace = true diff --git a/crates/ui/README.md b/crates/ui/README.md new file mode 100644 index 0000000..b40cb27 --- /dev/null +++ b/crates/ui/README.md @@ -0,0 +1,108 @@ +# Craft UI + +`craft-ui` provides small egui components with application-supplied styles. `icons` prepares +embedded SVG catalogs; `buttons` paints accessible icon buttons. PhotoCraft and PdfCraft use both +modules, and VectorCraft uses the catalog. Assets, complete themes, localization and command +handling remain in each application. No icons, fonts or application engines are bundled. + +## SVG catalogs + +```rust +use std::sync::OnceLock; +use craft_ui::icons::{SvgIconSet, SvgStyle}; + +// Supply the application's own attributed assets here. +static ICONS: &[(&str, &[u8])] = &[]; + +fn icons() -> &'static SvgIconSet { + static ICONS_SET: OnceLock = OnceLock::new(); + ICONS_SET.get_or_init(|| SvgIconSet::new( + ICONS, + SvgStyle { current_color: "#ffffff", stroke_width: Some("1.75") }, + "bytes://my-app/icons/", + "square", + )) +} + +fn icon(name: &str) -> egui::Image<'static> { + egui::Image::new(icons().source(name)) +} +``` + +The application installs `egui_extras::install_image_loaders` with its `svg` feature before drawing. +It sets tint and size on the returned image. +The library does not install loaders or an egui theme. + +The catalog is immutable. SVGs are prepared once and held in `Arc<[u8]>`; lookups share those bytes. +The fallback uses the requested name in its URI, matching PhotoCraft and VectorCraft. Missing both +the requested name and fallback produces empty bytes for the loader to report as an error, without +indexed lookups or a panic. `contains` and `canonical_name` inspect only actual catalog entries. PdfCraft resolves an unknown +name to `square` before calling `source`, preserving its fallback cache key. + +Use a different URI prefix for every catalog/style combination used in the same egui context. +egui caches by URI; supplying different bytes under one URI would reuse stale content. Apps with +their own contexts can retain their current prefixes. Unknown names do not add entries to this +catalog, though the egui loader may cache a requested URI. + +This is an adapter for trusted, compiled-in monochrome assets, not an SVG sanitizer. It performs +literal `currentColor` and optional `stroke-width="2"` substitutions, keeps other widths and explicit +colors untouched, and uses the existing lossy UTF-8 behavior. General SVG parsing and rasterization +remain the responsibility of the existing loader. + +## Icon buttons + +`buttons::IconButton` accepts a label, size and `IconButtonStyle`, and returns `egui::Response`. +`ButtonVisuals` describes fill, border and icon tint for normal, hovered, pressed and selected +states. Selection wins over press and hover. `pressed: None` keeps ordinary hover/normal styling; +`Some` supplies a separate press treatment. egui owns pointer capture and click cancellation. + +```rust +use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; +use egui::{Color32, CornerRadius, Stroke, Vec2}; + +fn layer_button(ui: &mut egui::Ui, selected: bool) -> egui::Response { + let normal = ButtonVisuals { + fill: Color32::TRANSPARENT, stroke: Stroke::NONE, icon: Color32::WHITE, + }; + let style = IconButtonStyle { + normal, + hovered: ButtonVisuals { fill: Color32::DARK_GRAY, ..normal }, + pressed: None, + selected: ButtonVisuals { fill: Color32::BLUE, ..normal }, + corner_radius: CornerRadius::same(4), + focus_stroke: Stroke::new(1.0, Color32::LIGHT_BLUE), + }; + IconButton::new("Layers", Vec2::splat(28.0), &style).selected(selected) + .show(ui, |ui, rect, tint| { + // Use the application's SVG catalog or its existing painter icon here. + ui.painter().circle_filled(rect.center(), 5.0, tint); + }) +} +``` + +Use actual theme colors in the style adapter and localized text for the label. The painter receives +the full button rectangle and icon tint; the app chooses icon padding/size. Parent disabled state +applies to painting, interaction and accessibility. The library reports label and selection to +AccessKit and draws the supplied inset focus outline. Mouse, Enter and Space activation come from +egui. The caller reads `clicked()`, runs its command and updates selection; the component neither +dispatches nor toggles state. Attach tooltips to the returned response. + +IDs come from the surrounding UI scope/position, independent of the translated label. Use +`ui.push_id(stable_id, ...)` when items reorder. Dimensions must be finite, positive and no larger +than 4096 points; other sizes produce an inert zero-size response. Invalid stroke widths are +ignored and large strokes are bounded to half the smaller button dimension. + +## Compatibility and checks + +The crate uses egui 0.36 and Rust 1.95 or later. Its production dependency disables egui's default +features so fonts and rendering features remain the application's choice. See the +[suite audit](../../docs/ui-components-audit.md) for candidates, policies and adoption order. + +Validation: + +```sh +cargo fmt --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +cargo check --target wasm32-unknown-unknown -p craft-ui +``` diff --git a/crates/ui/src/buttons/mod.rs b/crates/ui/src/buttons/mod.rs new file mode 100644 index 0000000..986ca9f --- /dev/null +++ b/crates/ui/src/buttons/mod.rs @@ -0,0 +1,109 @@ +//! Icon buttons with application-supplied colors and painting. + +use egui::{Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2}; + +/// The background, border and icon color for one interaction state. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ButtonVisuals { + pub fill: Color32, + pub stroke: Stroke, + pub icon: Color32, +} + +/// A small style adapter, independent of an application's complete theme. +#[derive(Clone, Copy, Debug)] +pub struct IconButtonStyle { + pub normal: ButtonVisuals, + pub hovered: ButtonVisuals, + /// `None` keeps the ordinary hover/normal appearance while pressed. Some apps have no + /// separate pressed treatment; others retain it when the pointer leaves the button. + pub pressed: Option, + /// Selection takes precedence over press and hover, matching the existing Craft buttons. + pub selected: ButtonVisuals, + pub corner_radius: CornerRadius, + /// An inset outline while the button has keyboard focus; `Stroke::NONE` opts out. + pub focus_stroke: Stroke, +} + +impl IconButtonStyle { + fn visuals(&self, selected: bool, hovered: bool, pressed: bool) -> ButtonVisuals { + if selected { + self.selected + } else if let Some(visuals) = self.pressed.filter(|_| pressed) { + visuals + } else if hovered { + self.hovered + } else { + self.normal + } + } +} + +/// An icon-only button. The accessible label is independent of the icon and the widget ID. +/// +/// The surrounding UI scope/position supplies the ID, just like `Ui::allocate_exact_size`; +/// use `Ui::push_id` for application-specific identity. The caller paints the icon, attaches +/// a tooltip if wanted, and dispatches commands from the returned response. Selection is +/// caller-owned and never toggled implicitly. Parent `Ui` enabled state applies normally. +pub struct IconButton<'a> { + label: &'a str, + size: Vec2, + selected: bool, + style: &'a IconButtonStyle, +} + +impl<'a> IconButton<'a> { + pub fn new(label: &'a str, size: Vec2, style: &'a IconButtonStyle) -> Self { + Self { label, size, selected: false, style } + } + + pub fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } + + /// Paint one button and return its mouse, keyboard and accessibility response. + /// + /// `paint_icon` receives the full button rectangle and the resolved icon tint. It may use + /// an SVG, painter shapes or another app-owned asset. Non-finite, non-positive or excessively + /// large dimensions (over 4096 points) produce an inert zero-size response without painting. + pub fn show(self, ui: &mut Ui, paint_icon: impl FnOnce(&Ui, Rect, Color32)) -> Response { + let valid = self.size.is_finite() && self.size.x > 0.0 && self.size.y > 0.0 && self.size.x <= 4096.0 && self.size.y <= 4096.0; + let (rect, response) = + ui.allocate_exact_size(if valid { self.size } else { Vec2::ZERO }, if valid { Sense::click() } else { Sense::hover() }); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, valid && ui.is_enabled(), self.selected, self.label)); + if !valid { + return response; + } + + let visuals = self.style.visuals(self.selected, response.hovered(), response.is_pointer_button_down_on()); + // Separate fill/stroke shapes preserve the consumers' existing rendering at the edges. + if visuals.fill != Color32::TRANSPARENT { + ui.painter().rect_filled(rect, self.style.corner_radius, visuals.fill); + } + let stroke = bounded_stroke(visuals.stroke, rect); + if !stroke.is_empty() { + ui.painter().rect_stroke(rect, self.style.corner_radius, stroke, StrokeKind::Inside); + } + paint_icon(ui, rect, visuals.icon); + if response.has_focus() && rect.width() > 4.0 && rect.height() > 4.0 { + let focus_rect = rect.shrink(2.0); + let stroke = bounded_stroke(self.style.focus_stroke, focus_rect); + if !stroke.is_empty() { + ui.painter().rect_stroke(focus_rect, self.style.corner_radius, stroke, StrokeKind::Inside); + } + } + response + } +} + +fn bounded_stroke(stroke: Stroke, rect: Rect) -> Stroke { + if stroke.width.is_finite() && stroke.width > 0.0 { + Stroke::new(stroke.width.min(rect.width().min(rect.height()) * 0.5), stroke.color) + } else { + Stroke::NONE + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui/src/buttons/tests.rs b/crates/ui/src/buttons/tests.rs new file mode 100644 index 0000000..77e5132 --- /dev/null +++ b/crates/ui/src/buttons/tests.rs @@ -0,0 +1,213 @@ +use super::*; +use egui::{Context, Event, FullOutput, Key, Modifiers, PointerButton, Pos2, RawInput, pos2, vec2}; + +fn style() -> IconButtonStyle { + let visuals = |fill, icon| ButtonVisuals { fill, stroke: Stroke::NONE, icon }; + IconButtonStyle { + normal: visuals(Color32::TRANSPARENT, Color32::WHITE), + hovered: visuals(Color32::DARK_GRAY, Color32::GREEN), + pressed: Some(visuals(Color32::BLUE, Color32::RED)), + selected: ButtonVisuals { fill: Color32::GRAY, stroke: Stroke::new(1.0, Color32::WHITE), icon: Color32::YELLOW }, + corner_radius: CornerRadius::same(4), + focus_stroke: Stroke::new(1.0, Color32::LIGHT_BLUE), + } +} + +struct ButtonTest { + ctx: Context, + style: IconButtonStyle, + enabled: bool, + selected: bool, + label: String, + size: Vec2, + clicks: usize, + tint: Option, +} + +impl ButtonTest { + fn new() -> Self { + let ctx = Context::default(); + ctx.enable_accesskit(); + ctx.global_style_mut(|s| s.animation_time = 0.0); + Self { ctx, style: style(), enabled: true, selected: false, label: "Inspect layers".into(), size: vec2(32.0, 32.0), clicks: 0, tint: None } + } + + fn frame(&mut self, events: Vec) -> (Response, FullOutput) { + let ctx = self.ctx.clone(); + let mut response = None; + self.tint = None; + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 200.0))), events, ..Default::default() }, |ui| { + let r = ui + .add_enabled_ui(self.enabled, |ui| { + IconButton::new(&self.label, self.size, &self.style).selected(self.selected).show(ui, |ui, rect, tint| { + self.tint = Some(tint); + ui.painter().circle_filled(rect.center(), 3.0, tint); + }) + }) + .inner; + self.clicks += usize::from(r.clicked()); + response = Some(r); + }); + output.textures_delta.clear(); + (response.unwrap(), output) + } + + fn settle(&mut self) -> Response { + self.frame(vec![]); + self.frame(vec![]).0 + } +} + +fn pointer(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +#[test] +fn click_activates_once_and_selection_stays_caller_owned() { + let mut h = ButtonTest::new(); + let center = h.settle().rect.center(); + h.frame(vec![Event::PointerMoved(center)]); + assert_eq!(h.tint, Some(Color32::GREEN)); + h.frame(vec![pointer(center, true)]); + assert_eq!(h.tint, Some(Color32::RED)); + assert_eq!(h.clicks, 0); + h.frame(vec![pointer(center, false)]); + h.frame(vec![]); + assert_eq!(h.clicks, 1); + assert!(!h.selected); +} + +#[test] +fn both_pressed_styles_cancel_activation_after_dragging_away() { + for separate_pressed in [false, true] { + let mut h = ButtonTest::new(); + if !separate_pressed { + h.style.pressed = None; + } + let inside = h.settle().rect.center(); + h.frame(vec![Event::PointerMoved(inside)]); + h.frame(vec![pointer(inside, true)]); + assert_eq!(h.tint, Some(if separate_pressed { Color32::RED } else { Color32::GREEN })); + // egui cancels the click candidate once the pointer exceeds its drag threshold. + let outside = pos2(300.0, 150.0); + h.frame(vec![Event::PointerMoved(outside)]); + assert_eq!(h.tint, Some(Color32::WHITE)); + h.frame(vec![pointer(outside, false)]); + assert_eq!(h.clicks, 0, "releasing outside must not invoke a command"); + } +} + +#[test] +fn selection_takes_precedence_and_is_exposed_to_accessibility() { + let mut h = ButtonTest::new(); + h.selected = true; + let center = h.settle().rect.center(); + let (_, output) = h.frame(vec![Event::PointerMoved(center), pointer(center, true)]); + assert_eq!(h.tint, Some(Color32::YELLOW)); + let tree = output.platform_output.accesskit_update.unwrap(); + let (_, node) = tree.nodes.iter().find(|(_, n)| n.label() == Some("Inspect layers")).unwrap(); + assert_eq!(node.role(), egui::accesskit::Role::Button); + assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True)); + assert!(!node.is_disabled()); +} + +#[test] +fn disabled_buttons_ignore_pointer_and_keyboard_activation() { + let mut h = ButtonTest::new(); + let response = h.settle(); + h.ctx.memory_mut(|m| m.request_focus(response.id)); + h.enabled = false; + let center = response.rect.center(); + h.frame(vec![Event::PointerMoved(center), pointer(center, true), key(Key::Enter, true)]); + let (r, output) = h.frame(vec![pointer(center, false), key(Key::Enter, false)]); + assert_eq!(h.clicks, 0); + assert!(!r.enabled()); + let tree = output.platform_output.accesskit_update.unwrap(); + let (_, node) = tree.nodes.iter().find(|(_, n)| n.label() == Some("Inspect layers")).unwrap(); + assert!(node.is_disabled()); +} + +#[test] +fn enter_and_space_activate_once_per_press() { + for key_code in [Key::Enter, Key::Space] { + let mut h = ButtonTest::new(); + let response = h.settle(); + h.ctx.memory_mut(|m| m.request_focus(response.id)); + h.frame(vec![]); + h.frame(vec![key(key_code, true)]); + h.frame(vec![key(key_code, false)]); + h.frame(vec![]); + assert_eq!(h.clicks, 1, "{key_code:?}"); + } +} + +#[test] +fn focus_has_an_inset_visible_outline() { + let mut h = ButtonTest::new(); + let response = h.settle(); + h.ctx.memory_mut(|m| m.request_focus(response.id)); + let (r, output) = h.frame(vec![]); + assert!(r.has_focus()); + assert!(output.shapes.iter().any(|s| matches!(&s.shape, egui::epaint::Shape::Rect(shape) + if shape.rect == r.rect.shrink(2.0) && shape.stroke == h.style.focus_stroke))); +} + +#[test] +fn translating_a_label_preserves_id_and_focus() { + let mut h = ButtonTest::new(); + let first = h.settle(); + h.ctx.memory_mut(|m| m.request_focus(first.id)); + h.label = "Inspecter les calques".into(); + let (second, output) = h.frame(vec![]); + assert_eq!(first.id, second.id); + assert!(second.has_focus()); + assert!(output.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some("Inspecter les calques"))); +} + +#[test] +fn duplicate_labels_have_distinct_ids_and_tab_visits_both() { + let ctx = Context::default(); + let style = style(); + let mut ids = Vec::new(); + let mut frame = |events| { + ids.clear(); + let mut out = ctx.run_ui(RawInput { events, ..Default::default() }, |ui| { + for _ in 0..2 { + let response = IconButton::new("Same label", Vec2::splat(24.0), &style).show(ui, |_, _, _| {}); + ids.push(response.id); + } + }); + out.textures_delta.clear(); + ids.clone() + }; + let ids = frame(vec![]); + assert_ne!(ids[0], ids[1]); + ctx.memory_mut(|m| m.request_focus(ids[0])); + frame(vec![]); + frame(vec![key(Key::Tab, true)]); + frame(vec![key(Key::Tab, false)]); + assert_eq!(ctx.memory(|m| m.focused()), Some(ids[1])); +} + +#[test] +fn hostile_sizes_are_inert_and_strokes_stay_finite() { + for size in [vec2(f32::NAN, 24.0), vec2(24.0, f32::INFINITY), vec2(-1.0, 24.0), Vec2::ZERO, Vec2::splat(f32::MAX)] { + let mut h = ButtonTest::new(); + h.size = size; + let (r, _) = h.frame(vec![key(Key::Enter, true)]); + assert_eq!(r.rect.size(), Vec2::ZERO); + assert_eq!(h.tint, None); + assert_eq!(h.clicks, 0); + assert!(!r.sense.senses_click()); + } + let rect = Rect::from_min_size(Pos2::ZERO, Vec2::splat(20.0)); + for width in [f32::NAN, f32::INFINITY, -1.0] { + assert_eq!(bounded_stroke(Stroke::new(width, Color32::WHITE), rect), Stroke::NONE); + } + assert_eq!(bounded_stroke(Stroke::new(f32::MAX, Color32::WHITE), rect).width, 10.0); +} diff --git a/crates/ui-icons/src/lib.rs b/crates/ui/src/icons/mod.rs similarity index 96% rename from crates/ui-icons/src/lib.rs rename to crates/ui/src/icons/mod.rs index 09dfbeb..a01596f 100644 --- a/crates/ui-icons/src/lib.rs +++ b/crates/ui/src/icons/mod.rs @@ -1,5 +1,3 @@ -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] - //! Prepare an application's embedded SVG icons once, then share their bytes with egui. //! //! The application owns its assets, tool-name mapping, theme, sizing and button behavior. diff --git a/crates/ui-icons/src/tests.rs b/crates/ui/src/icons/tests.rs similarity index 100% rename from crates/ui-icons/src/tests.rs rename to crates/ui/src/icons/tests.rs diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs new file mode 100644 index 0000000..4fae7dd --- /dev/null +++ b/crates/ui/src/lib.rs @@ -0,0 +1,9 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +//! Small, reusable egui components for the Craft frontends. +//! +//! Applications own their assets, themes, layouts, commands and document semantics. Components +//! accept focused style values and return egui responses; no application engine is required. + +pub mod buttons; +pub mod icons; diff --git a/docs/ui-components-audit.md b/docs/ui-components-audit.md index c41ae4e..6900e74 100644 --- a/docs/ui-components-audit.md +++ b/docs/ui-components-audit.md @@ -1,6 +1,6 @@ # Shared UI component audit -The best first extraction is the embedded SVG icon catalog used by PhotoCraft, VectorCraft and PdfCraft. It has a small boundary, real consumers and little dependence on application behavior. The pilot in this change puts that machinery in `craft-ui-icons` and adopts it in PhotoCraft and VectorCraft. +The best first extraction is the embedded SVG icon catalog used by PhotoCraft, VectorCraft and PdfCraft. It has a small boundary, real consumers and little dependence on application behavior. The pilot puts that machinery in `craft-ui::icons` and adopts it in all three apps. A second component, `craft-ui::buttons::IconButton`, is adopted by PhotoCraft and PdfCraft. The broader opportunity is a collection of reusable UI components. All twelve Rust Craft apps use egui 0.36, but their themes, document models, numeric fields and docking behavior have evolved differently. Shared components should accept small style values and return interactions; each app should continue to choose its layout, command, localization and document semantics. @@ -27,7 +27,7 @@ The inventory covers the twelve Rust editor apps plus ArtCraft and ArtCraftX. Ar | ArtCraft | React 18.3.1/Tauri; [frontend dependencies][ac-package], [component packages][ac-components], [Tailwind theme][ac-theme] | DOM focus and portals, draggable/resizable dialogs, media and generation state, hotkey ownership and service boundaries. | | ArtCraftX | React 18.3.1/Tauri; [frontend dependencies][ax-package], [component packages][ax-components], [Tailwind theme][ax-theme] | A related component family with different visual tokens and icon wrappers; Rust egui components are not a direct adoption path. | -The Rust applications keep egui at the frontend layer. The pilot follows that rule: only their UI crates depend on `craft-ui-icons`; engine, document and rendering crates do not. Both apps' layer checkers reject a direct dependency on the shared crate below L6. The crate depends on egui without enabling its default features. Apps retain their existing `egui_extras` SVG loader and renderer setup. +The Rust applications keep egui at the frontend layer. The pilot follows that rule: only their UI crates depend on `craft-ui`; engine, document and rendering crates do not. Their layer checkers reject a direct dependency on the shared crate below the UI layer: L6 in PhotoCraft/VectorCraft and L7 in PdfCraft. The crate depends on egui without enabling its default features. Apps retain their existing `egui_extras` SVG loader and renderer setup. ArtCraft and ArtCraftX already use Headless UI, Radix Dialog/Tooltip, Lucide React and resizable panels. Their component reuse should be evaluated within that stack. A shared visual vocabulary can cross the two toolkit families; Rust widget types cannot. @@ -37,15 +37,15 @@ These are proposed boundaries and adoption pairs, not a commitment to build a fr | Order | Component | Type of sharing | Initial consumers | Reference and reason | | --- | --- | --- | --- | --- | -| 1 | Embedded SVG catalog | Extract with two small style policies | PhotoCraft, VectorCraft; then PdfCraft | PhotoCraft's owned `Arc` bytes avoid leaked allocations; the existing loaders already have almost the same responsibilities. Implemented in the pilot. | -| 2 | Small controls and component styles | Share components | PdfCraft and PhotoCraft for buttons; GridCraft and DeckCraft for ribbon pieces | PdfCraft exposes accessibility metadata on its painted controls. Keep the caller's label, geometry, colors, enabled state and command handling. | +| 1 | Embedded SVG catalog | Extract with two small style policies | PhotoCraft, VectorCraft, PdfCraft | PhotoCraft's owned `Arc` bytes avoid leaked allocations; the existing loaders already have almost the same responsibilities. Implemented in the pilot. | +| 2 | Small controls and component styles | Share components | PdfCraft and PhotoCraft for buttons; GridCraft and DeckCraft for ribbon pieces | PdfCraft exposes accessibility metadata on its painted controls. Keep the caller's label, geometry, colors, enabled state and command handling. Icon buttons implemented; ribbon pieces remain proposed. | | 3 | Numeric editing state | Consolidate selected behavior | FilmCraft and EffectCraft, then LightCraft | EffectCraft adds finite-value checks and panel-local Tab behavior; LightCraft has explicit cancel/reset and step semantics. These are useful references, not interchangeable implementations. | | 4 | Swatch grid and color-field painting | Share components | WordCraft/GridCraft/DeckCraft for grids; PhotoCraft/VectorCraft for fields | The visual primitives overlap, while the values returned and color-management requirements differ. | | 5 | Menu viewport and navigation helpers | Consolidate selected behavior | PhotoCraft, LightCraft, VectorCraft | Scrolling, overflow and focus are reusable. Menu catalogs, native menus, shortcut resolution and dispatch remain app-owned. | | 6 | Ruler tick layout and painting | Standardize a primitive API | VectorCraft and DesignCraft, then PhotoCraft/WordCraft/DeckCraft | Share bounded tick placement and painting with caller-supplied labels and coordinates. Keep units, guide commands and paragraph markers outside. | | 7 | Dock layout operations | Compare an external library before extracting | FilmCraft, EffectCraft | Split/tab trees overlap substantially, but stacks, floating state and persisted schemas make this a migration project. | -Theme roles belong beside the components that consume them. A button might need a `ButtonStyle` containing its fills, border, text and focus colors. That does not require all apps to replace their `Tokens` structs or serialize the same theme enum. Panels, inspectors and dialogs can then reuse those controls while retaining their application-specific contents. +Theme roles belong beside the components that consume them. `IconButtonStyle` contains fills, borders, icon and focus colors for the implemented button. That does not require all apps to replace their `Tokens` structs or serialize the same theme enum. Panels, inspectors and dialogs can then reuse those controls while retaining their application-specific contents. ## Icons and the first extraction @@ -60,13 +60,13 @@ PhotoCraft embeds 118 SVGs, VectorCraft 259, and PdfCraft 184 through their `ico | Name used in fallback URI | Requested name | Requested name | Resolved fallback name | | Existing prepared storage | Shared `Arc` | Leaked static slice | Shared `Arc` | -`SvgIconSet` owns prepared bytes and returns egui image sources. Each app supplies its embedded catalog, literal SVG substitutions, URI prefix and fallback. Tool aliases, tint, size, hover/selected/pressed behavior, labels and tool cursors remain in the app. SVG parsing and rasterization stay with `egui_extras`. +`SvgIconSet` owns prepared bytes and returns egui image sources. Each app supplies its embedded catalog, literal SVG substitutions, URI prefix and fallback. Tool aliases, tint, size, labels and tool cursors remain in the app; the button component accepts the app's hover/selected/pressed style policy. SVG parsing and rasterization stay with `egui_extras`. -The pilot retains the two consumers' cache keys and exact SVG bytes. Its characterization tests cover all 377 catalog entries, tool aliases and missing-name fallbacks. Shared-crate tests cover both style policies, repeated shared-byte lookups, an absent fallback, duplicate names, lossy UTF-8 compatibility and actual SVG rasterization at 16, 24 and 48 pixels. See the [crate guide](../crates/ui-icons/README.md) for the API and limits. +The pilot retains all three consumers' cache keys and exact SVG bytes. Its characterization tests cover all 561 catalog entries, tool aliases and missing-name fallbacks. Shared-crate tests cover both style policies, repeated shared-byte lookups, an absent fallback, duplicate names, lossy UTF-8 compatibility and actual SVG rasterization at 16, 24 and 48 pixels. See the [crate guide](../crates/ui/README.md) for the API and limits. The SVG substitution is deliberately narrow: it preserves the existing literal replacements rather than becoming a general SVG/CSS processor. The catalog is trusted compiled-in data. A new set in the same egui context must use a distinct URI prefix, because egui caches by URI. The existing app prefixes remain appropriate when each app owns its context. -PdfCraft can adopt the same crate in a later change by resolving the fallback name before requesting a source. That preserves its existing URI policy. Its current fallback uses indexed lookup; an adoption should also cover a catalog without the expected fallback, with a regression test. No icon assets need to move to make this useful. +PdfCraft resolves the fallback name before requesting a source, preserving its existing URI policy. This replaces its indexed fallback lookup with the shared non-panicking lookup; the shared absent-fallback test covers a catalog without that entry. No icon assets move. Most other Rust apps draw icons with egui's painter. Converting them all to SVG would be a separate visual change with different cost and fidelity questions. Common icon assets are a future candidate after checking file hashes, ownership and each app's asset manifest; ArtCraft trademarks remain outside such a library. @@ -74,9 +74,9 @@ Most other Rust apps draw icons with egui's painter. Converting them all to SVG The `Tokens` name does not imply a common schema. PhotoCraft combines palette, layout radii, layer-label colors and histogram styling. DesignCraft includes high contrast and page-layout chrome. FilmCraft and EffectCraft include timeline/panel colors; GridCraft's theme also covers grid headers and selection. CADCraft and SoundCraft use domain-specific colors for drafting or transport states. The [theme sources above](#scope-and-current-libraries) show why replacing them with a union of every field would increase coupling. -For the next control extraction, start with an explicit style struct and a supplied icon painter. Return `egui::Response` or distinct main/arrow responses so the app owns dispatch. Prefer stable IDs supplied by the caller to IDs derived from translated labels. Keep dimensions configurable where they already differ. Use PdfCraft's [button and split-pill accessibility metadata][pdf-widgets] and LightCraft's [control descriptions and tests][lc-widgets] as references; appearance alone is insufficient. +The implemented icon button uses an explicit style struct and a supplied icon painter. It returns `egui::Response` so the app owns dispatch. A future split button should return distinct main/arrow responses. Prefer stable IDs supplied by the caller to IDs derived from translated labels. Keep dimensions configurable where they already differ. Use PdfCraft's [button and split-pill accessibility metadata][pdf-widgets] and LightCraft's [control descriptions and tests][lc-widgets] as references; appearance alone is insufficient. -An icon button or ribbon split button should be accepted only after mouse, keyboard activation, disabled behavior, focus indication, accessible label and main-versus-arrow hit regions are covered. Characterize existing behavior before deciding which accessibility improvements belong in a separate change. The first icon-catalog extraction does not alter button interaction. +An icon button or ribbon split button should be accepted only after mouse, keyboard activation, disabled behavior, focus indication, accessible label and main-versus-arrow hit regions are covered. The icon button deliberately adds a visible keyboard-focus outline in PhotoCraft and PdfCraft, and an accessible label/selection state in PhotoCraft. Existing hover, selected and pressed treatments remain app-specific. Tests cover click cancellation, keyboard activation, disabled state, label translation without losing identity, duplicate labels and Tab order, focus painting, and invalid geometry. Ribbon split buttons remain proposed. Inspectors remain app-owned. A layer inspector, a CAD property palette, a spreadsheet format panel and an audio mixer describe different document data and editing transactions. They can share labels, rows, numeric fields, sections and color chips without sharing a panel enum or pulling an engine into a UI utility crate. Similarly, common dialog framing can be separated from save confirmation, color sampling, export settings and progress/cancellation semantics.