collective

Browse / github.com/storytold/craft-libs / patch

Paint dialog tabs that join their page

Paint dialog tabs that join their page Reuse existing accessible tab interaction with caller-styled page-joining painting. Makes the reusable part of [WordCraft #4029](https://github.com/storytold/wordcraft/pull/4029) 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 s

Status
open
Score
0.00
Adopters
0 other operators
Author
fluorine (imported from pull request #271, open)
Base
2eca9b3a53a98ac52efed2a27353c6a6927ead10
Size
+176 −1 in 4 files
Review
pending
Shared
2026-10-28
Id
1b796164b2764729c5ed8f6d

How it went

No other operator has reported using it yet.

Diff

raw · crates/fonts-build/src/tests.rs crates/ui/docs/dialog-tabs.md crates/ui/src/tabs/dialog.rs crates/ui/src/tabs/mod.rs

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/dialog-tabs.md b/crates/ui/docs/dialog-tabs.mdnew file mode 100644--- /dev/null+++ b/crates/ui/docs/dialog-tabs.md@@ -0,0 +1,26 @@+# Dialog tabs that join their page++`Tab::show_dialog_at` adds the page-joining appearance proposed in+[WordCraft #4029](https://github.com/storytold/wordcraft/pull/4029), using the library's existing+accessible tab interaction. It suits settings and property dialogs across Craft apps.++Provide a `DialogTabStyle` with the application's colors, border, font and top corner radius.+Allocate rectangles using the application's layout and optionally `tabs::fit`; draw a baseline+before the tabs. Selected tabs use `style.page` and erase their bottom border so they join the+page. Use `style.border` for the baseline centered on the bottom edge; the selected cover extends+half its width below the interaction rectangle. Other sides and text retain their original clip.+The page should start at the tabs' bottom edge and use the same fill.++```rust+let response = craft_ui::tabs::Tab::new(stable_id, localized_label, selected)+    .show_dialog_at(ui, rect, &style);+if response.clicked() {+    // Update the application's selected page.+}+```++IDs must remain stable independently of translated labels. Selection, overflow, arrow-key+navigation and page content remain caller-owned. The underlying `Tab` supplies accessibility,+keyboard focus/activation, drag response and disabled behavior. Text clips to its rectangle;+invalid stroke widths become zero and invalid font sizes use 12 points. No app assets or theme+are bundled, and consumer migration is separate.diff --git a/crates/ui/src/tabs/dialog.rs b/crates/ui/src/tabs/dialog.rsnew file mode 100644--- /dev/null+++ b/crates/ui/src/tabs/dialog.rs@@ -0,0 +1,143 @@+//! Page-joining dialog tab painting on the existing accessible tab interaction.++use super::Tab;+use egui::{Align2, Color32, CornerRadius, FontId, Rect, Response, Stroke, StrokeKind, Ui, pos2};++/// A focused style adapter, not an application theme. The selected fill should match the page+/// below the strip. Draw the strip's baseline before its tabs: selected tabs erase their bottom+/// border to visually join that page. Applications own strip layout, overflow and page selection.+#[derive(Clone, Debug)]+pub struct DialogTabStyle {+    pub page: Color32,+    pub inactive: Color32,+    pub hovered: Color32,+    pub text: Color32,+    pub selected_text: Color32,+    /// Use this stroke for the strip baseline too. The selected seam covers half its width+    /// below the tab, matching a baseline centered on the bottom edge.+    pub border: Stroke,+    pub font: FontId,+    pub top_radius: u8,+}++impl Tab<'_> {+    /// Paint a raised dialog tab and return the same response as [`Tab::show_at`].+    ///+    /// Stable IDs, selected accessibility state, parent disabled state, keyboard activation and+    /// focus outlines all come from the existing tab. The application supplies localized text,+    /// geometry and style. Oversized labels clip within their tab instead of covering neighbours.+    /// The selected tab joins a page beginning at `rect.bottom()`; unselected tabs sit two points+    /// lower. No page is selected implicitly, and arrow-key navigation remains caller policy.+    pub fn show_dialog_at(self, ui: &mut Ui, rect: Rect, style: &DialogTabStyle) -> Response {+        let selected = self.selected;+        let label = self.label;+        self.show_at(ui, rect, |ui, response| {+            let inset = if selected { 0.0 } else { 2.0_f32.min(rect.height() * 0.25) };+            let shown = Rect::from_min_max(pos2(rect.left(), rect.top() + inset), rect.max);+            let width = if style.border.width.is_finite() { style.border.width.clamp(0.0, rect.width().min(rect.height()) * 0.5) } else { 0.0 };+            let stroke = Stroke::new(width, style.border.color);+            let fill = if selected {+                style.page+            } else if response.hovered() {+                style.hovered+            } else {+                style.inactive+            };+            let radius = CornerRadius { nw: style.top_radius, ne: style.top_radius, sw: 0, se: 0 };+            let mut paint_bounds = rect;+            if selected {+                paint_bounds.max.y += width * 0.5;+            }+            let painter = ui.painter().with_clip_rect(ui.clip_rect().intersect(paint_bounds));+            painter.rect(shown, radius, fill, stroke, StrokeKind::Inside);+            // Cover only the selected tab's bottom edge, preserving the vertical border corners.+            if selected && width > 0.0 && rect.width() > 2.0 * width {+                painter.rect_filled(+                    Rect::from_min_max(pos2(rect.left() + width, rect.bottom() - width), pos2(rect.right() - width, rect.bottom() + width * 0.5)),+                    0.0,+                    style.page,+                );+            }+            let text_rect = rect.shrink2(egui::vec2(2.0_f32.min(rect.width() * 0.25), 0.0));+            let color = if selected { style.selected_text } else { style.text };+            let font = FontId::new(if style.font.size.is_finite() { style.font.size.clamp(1.0, 512.0) } else { 12.0 }, style.font.family.clone());+            painter.with_clip_rect(text_rect.intersect(ui.clip_rect())).text(rect.center(), Align2::CENTER_CENTER, label, font, color);+        })+    }+}++#[cfg(test)]+mod tests {+    use super::*;+    use egui::{Id, Vec2};+    use egui_kittest::kittest::Queryable;++    fn style() -> DialogTabStyle {+        DialogTabStyle {+            page: Color32::WHITE,+            inactive: Color32::LIGHT_GRAY,+            hovered: Color32::GRAY,+            text: Color32::BLACK,+            selected_text: Color32::BLACK,+            border: Stroke::new(1.0, Color32::DARK_GRAY),+            font: FontId::proportional(12.0),+            top_radius: 5,+        }+    }++    #[test]+    fn painting_preserves_accessible_selection_and_click_response_with_duplicate_labels() {+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(400.0, 200.0)).build_ui_state(+            |ui, selected: &mut usize| {+                for index in 0..2 {+                    let (_, rect) = ui.allocate_space(Vec2::new(100.0, 26.0));+                    let response = Tab::new(Id::new(("dialog", index)), "Options", *selected == index).show_dialog_at(ui, rect, &style());+                    if response.clicked() {+                        *selected = index;+                    }+                }+            },+            0,+        );+        h.run_steps(2);+        let tabs: Vec<_> = h.get_all_by_label("Options").collect();+        assert_eq!(tabs.len(), 2);+        tabs[1].click();+        h.run_steps(2);+        assert_eq!(*h.state(), 1);+    }++    #[test]+    fn malformed_style_and_long_text_tessellate_inside_small_tab() {+        let ctx = egui::Context::default();+        let mut style = style();+        style.border.width = f32::NAN;+        style.font.size = f32::INFINITY;+        let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {+            let (_, rect) = ui.allocate_space(Vec2::new(5.0, 3.0));+            Tab::new(Id::new("small"), "A long localized label", true).show_dialog_at(ui, rect, &style);+        });+        out.textures_delta.clear();+        let _ = ctx.tessellate(out.shapes, out.pixels_per_point);+    }++    #[test]+    fn selected_tab_erases_a_baseline_centered_on_its_bottom_edge() {+        let mut h = egui_kittest::Harness::builder().with_size(Vec2::new(120.0, 80.0)).wgpu().build_ui(|ui| {+            ui.painter().rect_filled(ui.max_rect(), 0.0, Color32::WHITE);+            let rect = Rect::from_min_size(egui::pos2(10.0, 10.0), Vec2::new(100.0, 26.0));+            let mut style = style();+            style.border = Stroke::new(2.0, Color32::BLACK);+            ui.painter().line_segment([rect.left_bottom(), rect.right_bottom()], style.border);+            Tab::new(Id::new("joined"), "Options", true).show_dialog_at(ui, rect, &style);+        });+        h.ctx.set_pixels_per_point(1.0);+        h.run_steps(3);+        let image = h.render();+        assert!(image.is_ok());+        if let Ok(image) = image {+            let pixel = image.get_pixel(60, 36);+            assert!(pixel[0] >= 245 && pixel[1] >= 245 && pixel[2] >= 245, "seam remains visible: {pixel:?}");+        }+    }+}diff --git a/crates/ui/src/tabs/mod.rs b/crates/ui/src/tabs/mod.rs--- a/crates/ui/src/tabs/mod.rs+++ b/crates/ui/src/tabs/mod.rs@@ -6,6 +6,9 @@  use egui::{Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2}; +mod dialog;+pub use dialog::DialogTabStyle;+ /// Tabs visible on a strip and tabs that need an overflow menu, all in original order. #[derive(Clone, Debug, Default, PartialEq)] pub struct TabFit {