collective

Browse / github.com/storytold/craft-libs / patch

Add caller-styled dialog section frames

Add caller-styled dialog section frames Group related dialog and inspector settings in reusable frames with optional accessible headings. Makes the reusable part of [WordCraft #4038](https://github.com/storytold/wordcraft/pull/4038) 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 #269, open)
Base
2eca9b3a53a98ac52efed2a27353c6a6927ead10
Size
+225 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
fa47706abea4cb9dc4d88d71

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/src/layout/mod.rs crates/ui/src/layout/section.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/README.md b/crates/ui/README.md--- a/crates/ui/README.md+++ b/crates/ui/README.md@@ -3,12 +3,46 @@ `craft-ui` provides small egui components with application-supplied styles. `icons` prepares embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.+`layout::Section` groups dialog or inspector settings in caller-styled frames with optional headings. `docking` supplies transactional panel moves and an optional docking surface. `printing` supplies printer/job controls and asynchronous feedback used by WordCraft and GridCraft. The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance) records their scope and acceptance limits. Assets, complete themes, localization and command handling remain in each application. No icons, fonts or application engines are bundled. +## Dialog and inspector sections++`layout::Section` groups related settings without owning a window, theme or commands. Pass a+localized heading and a frame built from the application's tokens. Typography stays in the supplied+`WidgetText`; contents keep normal egui focus, clipping and parent disabled state.++```rust+use craft_ui::layout::Section;+use egui::{CornerRadius, Frame, Margin, RichText};++fn display_settings(ui: &mut egui::Ui, show_grid: &mut bool) {+    let frame = Frame::new()+        .fill(ui.visuals().window_fill.lerp_to_gamma(ui.visuals().text_color(), 0.04))+        .stroke(ui.visuals().window_stroke)+        .corner_radius(CornerRadius::same(6))+        .inner_margin(Margin::symmetric(10, 8));+    Section::new(frame).heading(RichText::new("Display").strong()).show(ui, |ui| {+        ui.checkbox(show_grid, "Show grid");+    });+}+```++The default fills available width; `.fill_width(false)` wraps the contents. An empty heading adds+no row or heading gap. The heading remains above the contents even in a horizontal parent.+The helper neither indents controls nor adds exterior spacing: compose those+in the caller's layout. Reusing the same frame for nested sections preserves its fill without+accumulating tint or changing global visuals. Invalid floating-point decoration becomes zero and+large heading gaps/strokes are capped at 64 logical points; forced widths are capped at one million.++This small framing boundary comes from [WordCraft #4038](https://github.com/storytold/wordcraft/pull/4038)+and is also suitable for GridCraft/DeckCraft dialogs and creative-app inspectors. Consumer adoption+is separate; application-specific dialog behavior remains in each app.+ ## SVG catalogs  ```rustdiff --git a/crates/ui/src/layout/mod.rs b/crates/ui/src/layout/mod.rs--- a/crates/ui/src/layout/mod.rs+++ b/crates/ui/src/layout/mod.rs@@ -6,10 +6,12 @@  mod geometry; mod resize;+mod section; mod toolbar;  pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect}; pub use resize::{ResizeHandle, ResizeHandleStyle, ResizeResponse};+pub use section::Section; pub use toolbar::{ToolbarFit, fit_toolbar};  #[cfg(test)]diff --git a/crates/ui/src/layout/section.rs b/crates/ui/src/layout/section.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/layout/section.rs@@ -0,0 +1,185 @@+//! Group related settings without taking ownership of a dialog or its contents.++use egui::{Frame, InnerResponse, Label, Stroke, Ui, WidgetText};++const MAX_WIDTH: f32 = 1_000_000.0;+const MAX_DECORATION: f32 = 64.0;++/// A caller-styled frame with an optional heading above its contents.+///+/// This extracts the grouping boundary of WordCraft's dialog sections (#4038), not its theme,+/// fonts, indentation or window policy. Pass localized [`WidgetText`] (including [`egui::RichText`]+/// for emphasis) and an application-supplied [`Frame`]. The contents retain ordinary egui focus,+/// clipping, disabled-state and widget-ID behavior. Use `Ui::push_id` around repeated sections+/// when the application needs identity independent of their position.+///+/// Reusing one frame for nested sections keeps their fill consistent: this component neither+/// adds another tint nor writes temporary global style or nesting state.+pub struct Section {+    frame: Frame,+    heading: Option<WidgetText>,+    heading_gap: f32,+    fill_width: bool,+}++impl Section {+    pub fn new(frame: Frame) -> Self {+        Self { frame, heading: None, heading_gap: 2.0, fill_width: true }+    }++    /// Put a heading above the contents. An empty heading takes no row or gap.+    ///+    /// Typography is caller-owned, for example `RichText::new(title).strong()`.+    pub fn heading(mut self, heading: impl Into<WidgetText>) -> Self {+        let heading = heading.into();+        self.heading = (!heading.text().is_empty()).then_some(heading);+        self+    }++    /// Space after a nonempty heading, in logical points. Invalid or negative values become zero;+    /// excessive values are capped at 64 points. This does not add space outside the frame.+    pub fn heading_gap(mut self, points: f32) -> Self {+        self.heading_gap = decoration(points);+        self+    }++    /// Fill the available width (the default), or wrap the contents when false.+    /// Unbounded parent widths do not force an unbounded minimum width.+    pub fn fill_width(mut self, fill: bool) -> Self {+        self.fill_width = fill;+        self+    }++    /// Lay out the heading and caller's controls, returning the caller's value and frame response.+    ///+    /// The callback owns its state and actions; the section does not dispatch commands, create+    /// a modal window, indent controls or change global visuals. egui clamps narrow frame content+    /// rectangles. This component additionally bounds forced widths and sanitizes the frame's+    /// floating-point stroke width (invalid values become zero, maximum 64 points).+    pub fn show<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {+        let mut frame = self.frame;+        let width = decoration(frame.stroke.width);+        frame.stroke = if width == 0.0 { Stroke::NONE } else { Stroke::new(width, frame.stroke.color) };+        frame.show(ui, |ui| {+            // A frame inherits its parent's layout. Keep the section's heading above its body+            // even when the caller places several sections side by side in a horizontal row.+            ui.vertical(|ui| {+                if self.fill_width {+                    let width = ui.available_width();+                    if width.is_finite() && width > 0.0 {+                        ui.set_width(width.min(MAX_WIDTH));+                    }+                }+                if let Some(heading) = self.heading {+                    ui.add(Label::new(heading));+                    ui.add_space(self.heading_gap);+                }+                add_contents(ui)+            })+            .inner+        })+    }+}++fn decoration(value: f32) -> f32 {+    if value.is_finite() { value.clamp(0.0, MAX_DECORATION) } else { 0.0 }+}++#[cfg(test)]+mod tests {+    use super::*;+    use egui::{Color32, Context, CornerRadius, Event, Margin, Modifiers, PointerButton, RawInput, RichText, Vec2};+    use egui_kittest::kittest::Queryable;++    fn style() -> Frame {+        Frame::new()+            .fill(Color32::from_gray(30))+            .stroke(Stroke::new(1.0, Color32::GRAY))+            .corner_radius(CornerRadius::same(6))+            .inner_margin(Margin::same(8))+    }++    #[test]+    fn heading_stays_above_controls_in_horizontal_parents() {+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 300.0)).build_ui(|ui| {+            ui.horizontal(|ui| {+                Section::new(style()).heading("Display").fill_width(false).show(ui, |ui| {+                    let _ = ui.button("Apply");+                });+                ui.label("Neighbour");+            });+        });+        h.run_steps(2);+        assert!(h.get_by_label("Apply").rect().top() >= h.get_by_label("Display").rect().bottom());+    }++    #[test]+    fn heading_and_content_are_accessible_and_return_the_callers_value() {+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 300.0)).build_ui_state(+            |ui, clicked: &mut bool| {+                let result = Section::new(style()).heading(RichText::new("Display").strong()).show(ui, |ui| {+                    if ui.button("Apply").clicked() {+                        *clicked = true;+                    }+                    42+                });+                assert_eq!(result.inner, 42);+            },+            false,+        );+        h.run_steps(2);+        h.get_by_label("Display");+        h.get_by_label("Apply").click();+        h.run_steps(2);+        assert!(*h.state());+    }++    #[test]+    fn parent_disabled_state_prevents_content_activation() {+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(500.0, 300.0)).build_ui_state(+            |ui, state: &mut (bool, usize)| {+                ui.add_enabled_ui(state.0, |ui| {+                    Section::new(style()).heading("Display").show(ui, |ui| {+                        if ui.button("Apply").clicked() {+                            state.1 += 1;+                        }+                    });+                });+            },+            (true, 0),+        );+        h.run_steps(2);+        let pos = h.get_by_label("Apply").rect().center();+        h.state_mut().0 = false;+        h.run_steps(2);+        h.event(Event::PointerMoved(pos));+        h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });+        h.step();+        h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });+        h.run_steps(2);+        assert_eq!(h.state().1, 0);+    }++    #[test]+    fn nested_sections_do_not_change_theme_and_invalid_decoration_stays_finite() {+        let ctx = Context::default();+        let original = ctx.global_style().visuals.clone();+        for value in [f32::NAN, f32::INFINITY, -1.0, f32::MAX] {+            let mut frame = style();+            frame.stroke.width = value;+            let mut out = ctx.run_ui(RawInput::default(), |ui| {+                Section::new(frame).heading("").heading_gap(value).show(ui, |ui| {+                    let response = Section::new(frame).heading("Nested").heading_gap(value).show(ui, |ui| {+                        ui.label("Contents");+                    });+                    assert!(response.response.rect.is_finite());+                });+            });+            out.textures_delta.clear();+            let _ = ctx.tessellate(out.shapes, out.pixels_per_point);+            assert_eq!(ctx.global_style().visuals.panel_fill, original.panel_fill);+            assert_eq!(ctx.global_style().visuals.window_fill, original.window_fill);+            assert_eq!(ctx.global_style().visuals.window_stroke, original.window_stroke);+        }+    }+}