Browse / github.com/storytold/craft-libs / patch
Add bounded dialog content indentation
Add bounded dialog content indentation Support fractional, decoration-free field indentation without changing global spacing. 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 th
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- fluorine (imported from pull request #270, open)
- Base
16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3- Size
- +139 −1 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2131504c4c63b41ee5563f91
How it went
No other operator has reported using it yet.
Diff
raw · .gitignore crates/fonts-build/src/tests.rs crates/ui/docs/content-inset.md crates/ui/src/layout/inset.rs crates/ui/src/layout/mod.rs
@@ -6,3 +6,4 @@ plans/ /.claude/ .idea/ /vendor/*/target+/.project-map/@@ -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]new file mode 100644@@ -0,0 +1,22 @@+# Dialog field indentation++`craft_ui::layout::ContentInset` places subordinate controls under a section heading without+adding a tree guide or changing the application's global indentation settings. It supports+fractional theme spacing and horizontal parents, and bounds padding in narrow containers.++```rust+ui.label("Display"); // localize in the application+craft_ui::layout::ContentInset::new(12.5).show(ui, |ui| {+ ui.checkbox(show_grid, "Show grid");+});+```++Add `.right(points)` for trailing space. Left/right mean physical sides; applications choose+the appropriate padding for their language and layout. Both insets are included in the consumed+scope, so following horizontal siblings retain the gap. The scope inherits layout, clipping and+disabled state. It adds no vertical space or frame. Use it independently or inside a caller-styled+section. Invalid or negative padding becomes zero; finite padding is capped at one million logical+points, then at available width, with the left side taking priority.++Extracted from the indentation concern in [WordCraft #4038](https://github.com/storytold/wordcraft/pull/4038).+This also suits property inspectors and settings panels in other Craft apps. Consumer adoption is separate.new file mode 100644@@ -0,0 +1,110 @@+//! Decoration-free indentation for dialog fields and inspector subgroups.++use egui::{InnerResponse, Rect, Ui, UiBuilder, pos2};++/// Physical left/right padding around a scope, in logical points.+///+/// Unlike a tree indent, this has no guide line and does not require a vertical parent. It+/// supports fractional theme spacing without changing global `Spacing::indent` or visuals.+/// The child inherits layout, enabled state and clipping; choose an inner layout as usual when+/// placing a vertical set of fields inside a horizontal parent. Translation, RTL padding policy,+/// widget identity and whether controls are enabled remain the application's responsibility.+#[derive(Clone, Copy, Debug, Default)]+pub struct ContentInset {+ left: f32,+ right: f32,+}++impl ContentInset {+ /// Indent from the left. Negative/non-finite values become zero; finite values are capped+ /// at one million points and further restricted to the available width when shown.+ pub fn new(left: f32) -> Self {+ Self { left: amount(left), right: 0.0 }+ }++ pub fn right(mut self, right: f32) -> Self {+ self.right = amount(right);+ self+ }++ /// Allocate the child's used space and return its value and response. The left inset takes+ /// priority in narrow parents; the right inset uses the remaining width. Insets introduce+ /// no vertical gap or frame. Repeated controls can use an enclosing `Ui::push_id`.+ pub fn show<R>(self, ui: &mut Ui, add_contents: impl FnOnce(&mut Ui) -> R) -> InnerResponse<R> {+ let mut rect = ui.available_rect_before_wrap();+ let left = self.left.min(rect.width().max(0.0));+ rect.min.x += left;+ let right = self.right.min(rect.width().max(0.0));+ rect.max.x -= right;+ ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {+ let inner = add_contents(ui);+ // Wrapping uses available width, but the parent consumes only the used rectangle.+ // Reserve both physical insets after layout without changing content positions/IDs.+ let used = ui.min_rect();+ ui.expand_to_include_rect(Rect::from_min_max(pos2(used.left() - left, used.top()), pos2(used.right() + right, used.bottom())));+ inner+ })+ }+}++fn amount(value: f32) -> f32 {+ if value.is_finite() { value.clamp(0.0, 1_000_000.0) } else { 0.0 }+}++#[cfg(test)]+mod tests {+ use super::*;+ use egui::{Context, RawInput, Rect, Vec2};++ #[test]+ fn trailing_padding_separates_a_following_horizontal_sibling() {+ let ctx = Context::default();+ ctx.run_ui(RawInput::default(), |ui| {+ ui.horizontal(|ui| {+ let field = ContentInset::new(12.5).right(9.25).show(ui, |ui| ui.button("Field"));+ let next = ui.button("Next");+ assert!((field.response.rect.right() - field.inner.rect.right() - 9.25).abs() < 0.01);+ assert!(next.rect.left() >= field.inner.rect.right() + 9.25 + ui.spacing().item_spacing.x - 0.01);+ });+ })+ .drop_without_applying_deltas();+ }++ #[test]+ fn fractional_padding_keeps_fields_below_heading_and_following_rows_below_contents() {+ let ctx = Context::default();+ ctx.run_ui(RawInput::default(), |ui| {+ let heading = ui.label("Settings");+ let original_indent = ui.spacing().indent;+ let fields = ContentInset::new(12.5).right(4.25).show(ui, |ui| {+ assert!((ui.max_rect().left() - heading.rect.left() - 12.5).abs() < 0.01);+ ui.label("First");+ ui.label("Second");+ 7+ });+ assert_eq!(fields.inner, 7);+ let footer = ui.label("Footer");+ assert!(footer.rect.top() >= fields.response.rect.bottom());+ assert_eq!(ui.spacing().indent, original_indent);+ })+ .drop_without_applying_deltas();+ }++ #[test]+ fn narrow_and_horizontal_parents_and_invalid_insets_do_not_invert_child_rectangles() {+ let ctx = Context::default();+ for value in [f32::NAN, f32::INFINITY, -1.0, 0.0, 12.5, f32::MAX] {+ ctx.run_ui(RawInput::default(), |ui| {+ ui.horizontal(|ui| {+ ui.scope_builder(UiBuilder::new().max_rect(Rect::from_min_size(ui.cursor().min, Vec2::new(5.0, 50.0))), |ui| {+ ContentInset::new(value).right(value).show(ui, |ui| {+ assert!(ui.max_rect().is_finite());+ assert!(ui.max_rect().width() >= 0.0);+ });+ });+ });+ })+ .drop_without_applying_deltas();+ }+ }+}@@ -5,10 +5,12 @@ //! that application state. mod geometry;+mod inset; mod resize; mod toolbar; pub use geometry::{SplitAxis, SplitLayout, SplitSize, split_rect};+pub use inset::ContentInset; pub(crate) use resize::drag_focus; pub use resize::{ResizeHandle, ResizeHandleStyle, ResizeResponse}; pub use toolbar::{ToolbarFit, fit_toolbar};