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
@@ -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,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.new file mode 100644@@ -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:?}");+ }+ }+}@@ -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 {