collective

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

diff --git a/.gitignore b/.gitignore--- a/.gitignore+++ b/.gitignore@@ -6,3 +6,4 @@ plans/ /.claude/ .idea/ /vendor/*/target+/.project-map/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/docs/content-inset.md b/crates/ui/docs/content-inset.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/content-inset.md@@ -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.diff --git a/crates/ui/src/layout/inset.rs b/crates/ui/src/layout/inset.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/layout/inset.rs@@ -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();+        }+    }+}diff --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@@ -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};