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).

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
<!-- coderabbit-ai-generated:v1 -->

## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Joseph Anglada authored and GitHub committed 2026-11-11 07:31:25 +01:00
1 parent 5a93050c9f
commit 49d865dbdd
3 files changed
+224

No files matched your search

+34
View File
@@ -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
+2
View File
@@ -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)]
+188
View File
@@ -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<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 {
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);
}
}
}