From 49d865dbdd2d2ef9b79fce3e8d868d0da37cd2f0 Mon Sep 17 00:00:00 2001 From: Joseph Anglada Date: Wed, 11 Nov 2026 02:31:25 -0400 Subject: [PATCH] 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. Includes the shared test-portability prerequisite [#267](https://github.com/storytold/craft-libs/pull/267); the feature itself is independent of the other UI proposals. [Integration guide](https://github.com/fluorine/craft-libs/blob/fad5c05170d43f9bdf86c6063131e28669fcb176/crates/ui/README.md). ## Summary by CodeRabbit * **New Features** * Added customizable sections for grouping content vertically, with optional headings and configurable spacing. * Sections can fill the available width or use a caller-defined width, and return the result of their content callback. * **Documentation** * Added usage guidance and an example for styling sections in dialogs and inspector panels. --- crates/ui/README.md | 34 ++++++ crates/ui/src/layout/mod.rs | 2 + crates/ui/src/layout/section.rs | 188 ++++++++++++++++++++++++++++++++ 3 files changed, 224 insertions(+) create mode 100644 crates/ui/src/layout/section.rs diff --git a/crates/ui/README.md b/crates/ui/README.md index 888828db..acf061a4 100644 --- a/crates/ui/README.md +++ b/crates/ui/README.md @@ -3,6 +3,7 @@ `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. `fonts` installs the UI font stack, the same fallback order in every app. @@ -56,6 +57,39 @@ sizes, the viewport edge, and top-gutter opt-out geometry. Exhaustive `DockStyle` struct literals need `dock_gap: None`; constructors such as `from_ui` and `from_visuals` supply the compatible default. +## 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. A translucent fill +blends again in each nested section; the component does not change 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 ```rust diff --git a/crates/ui/src/layout/mod.rs b/crates/ui/src/layout/mod.rs index a6ffed84..7c1a2db7 100644 --- a/crates/ui/src/layout/mod.rs +++ b/crates/ui/src/layout/mod.rs @@ -6,11 +6,13 @@ mod geometry; mod resize; +mod section; mod toolbar; pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect}; pub(crate) use resize::drag_focus; 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.rs new file mode 100644 index 00000000..a6f6870f --- /dev/null +++ b/crates/ui/src/layout/section.rs @@ -0,0 +1,188 @@ +//! Group related settings without taking ownership of a dialog or its contents. + +use egui::{Frame, InnerResponse, Label, Role, Stroke, Ui, WidgetInfo, 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, + 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) -> 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(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse { + 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 { + let name = heading.text().to_owned(); + let label = ui.add(Label::new(heading)); + label.widget_info(|| WidgetInfo::labeled(Role::Heading, ui.is_enabled(), &name)); + 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::{NodeT, 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"); + assert_eq!(h.get_by_label("Display").accesskit_node().role(), egui::accesskit::Role::Heading); + 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); + } + } +}