Browse / github.com/storytold/craft-libs / patch
Share toolbar and inspector group glow
Share toolbar and inspector group glow Paint bounded accent feedback behind groups without intercepting child interactions. Makes the reusable part of [WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781) 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. Includes the
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- fluorine (imported from pull request #274, open)
- Base
2eca9b3a53a98ac52efed2a27353c6a6927ead10- Size
- +103 −1 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
f7041777da43a2dbaa590a1b
How it went
No other operator has reported using it yet.
Diff
raw · crates/fonts-build/src/tests.rs crates/ui/docs/group-glow.md crates/ui/src/theme/glow.rs crates/ui/src/theme/mod.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]new file mode 100644@@ -0,0 +1,21 @@+# Shared group hover glow++`theme::edge_glow` shares the bottom-edge accent glow from+[WordCraft #3781](https://github.com/storytold/wordcraft/pull/3781). It can highlight ribbon groups+and inspector sections without adding a second interactive widget over their child controls.++Reserve a shape behind the group before rendering its contents, then replace it once its+rectangle is known:++```rust+let slot = ui.painter().add(egui::Shape::Noop);+// Render controls and obtain group_rect here.+let over = ui.is_enabled() && ui.rect_contains_pointer(group_rect);+let strength = ui.ctx().animate_bool_responsive(group_id.with("glow"), over);+ui.painter().set(slot, craft_ui::theme::edge_glow(group_rect, accent, strength, 9.0));+```++Set global egui animation time to zero for the host's motion-off preference. The painter obeys+its normal clip rectangle. The glow stays inside its group and respects the supplied accent's+alpha; use a translucent accent for a subtle effect. Invalid geometry/strength produces no shape.+Theme, hover policy, stable group identity, enabled state and layout remain caller-owned.new file mode 100644@@ -0,0 +1,75 @@+use egui::{Color32, Rect, Shape, pos2};++/// An accent glow rising from the bottom edge of a toolbar group or inspector section.+///+/// This is painting only: it adds no widget, hit target, layout space, or application state.+/// Reserve a `Shape::Noop` before drawing the group's controls and replace it afterward with+/// this shape so it stays behind text. The caller supplies hover strength (usually egui's+/// `animate_bool_responsive`), color, clipping and motion preference. Stable group IDs keep+/// animations independent of translated headings. Hover should include the group's children+/// but respect popup/layer occlusion, e.g. `ui.rect_contains_pointer(group_rect)`.+///+/// `height` is bounded to the rectangle's height and at most 4096 logical points. Strength is+/// clamped to 0..1 and multiplies the supplied color's alpha. Non-finite/empty geometry or+/// non-positive strength/height produces `Shape::Noop`. The glow remains inside `rect`, fading+/// at its sides as well as upward, so it cannot cover neighbouring groups or leak past a panel.+pub fn edge_glow(rect: Rect, color: Color32, strength: f32, height: f32) -> Shape {+ if !rect.is_finite()+ || rect.width() <= 0.0+ || rect.height() <= 0.0+ || rect.size().max_elem() > 1_000_000.0+ || !strength.is_finite()+ || strength <= 0.0+ || !height.is_finite()+ || height <= 0.0+ {+ return Shape::Noop;+ }+ let height = height.min(rect.height()).min(4096.0);+ let soft = (rect.width() * 0.3).min(22.0);+ let xs = [rect.left(), rect.left() + soft, rect.right() - soft, rect.right()];+ let mut mesh = egui::Mesh::default();+ for (y, vertical) in [(rect.bottom(), 1.0), (rect.bottom() - height, 0.0)] {+ for (i, &x) in xs.iter().enumerate() {+ let side = if i == 0 || i == 3 { 0.0 } else { 1.0 };+ mesh.colored_vertex(pos2(x, y), color.gamma_multiply(strength.min(1.0) * vertical * side));+ }+ }+ for i in 0..3_u32 {+ mesh.add_triangle(i, i + 1, i + 5);+ mesh.add_triangle(i, i + 5, i + 4);+ }+ Shape::mesh(mesh)+}++#[cfg(test)]+mod tests {+ use super::*;+ #[test]+ fn glow_stays_inside_group_and_multiplies_alpha_without_opaque_edges() {+ let rect = Rect::from_min_size(pos2(20.0, 30.0), egui::vec2(100.0, 40.0));+ let shape = edge_glow(rect, Color32::from_rgba_unmultiplied(20, 100, 200, 100), 0.5, 9.0);+ assert!(matches!(shape, Shape::Mesh(_)), "expected mesh");+ let Shape::Mesh(mesh) = shape else {+ return;+ };+ assert_eq!(mesh.vertices.len(), 8);+ assert_eq!(mesh.indices.len(), 18);+ assert!(mesh.is_valid());+ assert!(mesh.vertices.iter().all(|v| rect.contains(v.pos) && v.color.a() <= 50));+ assert!(mesh.vertices.iter().skip(4).all(|v| v.color == Color32::TRANSPARENT));+ assert_eq!(mesh.vertices[0].color, Color32::TRANSPARENT);+ assert!(mesh.vertices[1].color.a() > 0);+ }+ #[test]+ fn hostile_input_and_zero_strength_are_inert() {+ for rect in [Rect::NOTHING, Rect::NAN, Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(0.0, 3.0))] {+ assert!(matches!(edge_glow(rect, Color32::WHITE, 1.0, 9.0), Shape::Noop));+ }+ let rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(20.0, 10.0));+ for value in [f32::NAN, f32::INFINITY, -1.0, 0.0] {+ assert!(matches!(edge_glow(rect, Color32::WHITE, value, 9.0), Shape::Noop));+ assert!(matches!(edge_glow(rect, Color32::WHITE, 1.0, value), Shape::Noop));+ }+ }+}@@ -5,6 +5,9 @@ use egui::{Color32, CornerRadius, Stroke, style::Widgets}; +mod glow;+pub use glow::edge_glow;+ /// Background colors for egui's five widget interaction states. /// /// These are rendering inputs, not a theme schema: applications construct them from their own