mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 21:12:18 +00:00
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:
1 parent
5a93050c9f
commit
49d865dbdd
3 files changed
+224
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user