collective

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

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/docs/group-glow.md b/crates/ui/docs/group-glow.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/group-glow.md@@ -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.diff --git a/crates/ui/src/theme/glow.rs b/crates/ui/src/theme/glow.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/theme/glow.rs@@ -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));+        }+    }+}diff --git a/crates/ui/src/theme/mod.rs b/crates/ui/src/theme/mod.rs--- a/crates/ui/src/theme/mod.rs+++ b/crates/ui/src/theme/mod.rs@@ -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