Skip to main content

teksilo_widgets/styles/
recipe_dialog_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `DialogStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeDialogStyle` ships the IntUI modal chrome:
7//! [`DialogStyle::make_panel`] builds a `DialogPanel` frame — a rounded
8//! `surface_main` panel with a `border_strong` stroke and the dialog
9//! content-padding inset — and [`DialogStyle::make_scrim`] builds the
10//! full-window dimming scrim (`SurfaceRole::Scrim`).
11//!
12//! The modal-presentation pipeline owns *mounting* both surfaces;
13//! `RecipeDialogStyle` only owns their look. Apps that want a different
14//! modal chrome (frosted-glass panel, no scrim, branded border) write
15//! their own `impl DialogStyle` block and install it per-call or
16//! theme-wide (`theme.style_slots.dialog`).
17
18use teksilo_canvas::{Canvas, Rect, Size, SizeProposal};
19use teksilo_core::accessibility::AccessNodeBuilder;
20use teksilo_core::build_context::BuildContext;
21use teksilo_core::styles::{DialogStyle, DialogStyleConfig};
22use teksilo_core::widget::{
23    LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget, WidgetPlacement,
24};
25use teksilo_core::widget_id::WidgetId;
26use teksilo_tokens::{CornerRadius, InputTokens, SurfaceRole, TargetRole};
27
28use crate::primitives::RectWidget;
29use teksilo_core::styles::density::{dp, spacing};
30
31// IntUI design tokens for Dialog. The recipe owns its own dimensions.
32pub const DIALOG_CONTENT_PADDING: f32 = 24.0;
33pub const DIALOG_MIN_WIDTH: f32 = 280.0;
34pub const DIALOG_CORNER_RADIUS: f32 = 8.0;
35
36/// Dimension bundle for [`RecipeDialogStyle`]. All fields have defaults
37/// driven by the module-level `DIALOG_*` consts.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct DialogRecipe {
40    pub content_padding: f32,
41    pub min_width: f32,
42    pub corner_radius: f32,
43}
44
45impl DialogRecipe {
46    /// This recipe's dimensions resolved against a density's [`InputTokens`].
47    ///
48    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
49    /// ladder — so the shipped values below are the Compact column by
50    /// construction and cannot drift from it.
51    pub fn for_tokens(tokens: &InputTokens) -> Self {
52        Self {
53            content_padding: spacing(DIALOG_CONTENT_PADDING, tokens),
54            min_width: dp(DIALOG_MIN_WIDTH, TargetRole::Target, tokens),
55            corner_radius: DIALOG_CORNER_RADIUS,
56        }
57    }
58}
59
60impl Default for DialogRecipe {
61    fn default() -> Self {
62        Self::for_tokens(&InputTokens::default())
63    }
64}
65
66/// Default `DialogStyle` shipped with Teksilo. Panel chrome is the
67/// rounded `surface_main` surface + `border_strong` stroke; the scrim
68/// is a plain `SurfaceRole::Scrim` fill.
69#[derive(Debug, Default, Clone, Copy)]
70pub struct RecipeDialogStyle {
71    pub recipe: DialogRecipe,
72}
73
74impl RecipeDialogStyle {
75    pub fn new(recipe: DialogRecipe) -> Self {
76        Self { recipe }
77    }
78
79    /// This style with every dimension resolved against a density's
80    /// [`InputTokens`], as `RecipeDialogStyle::for_tokens(&ctx.theme().input)` at
81    /// the widget's own build site.
82    ///
83    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
84    /// tree gets exactly the values this module documents.
85    pub fn for_tokens(tokens: &InputTokens) -> Self {
86        Self {
87            recipe: DialogRecipe::for_tokens(tokens),
88        }
89    }
90}
91
92impl DialogStyle for RecipeDialogStyle {
93    fn make_panel(&self, cfg: &DialogStyleConfig, ctx: &mut BuildContext) -> WidgetId {
94        ctx.add(DialogPanel {
95            child_id: None,
96            pending_child: Some(PendingChild::Id(cfg.content)),
97            padding: cfg.padding_override.unwrap_or(self.recipe.content_padding),
98            min_width: cfg.min_width_override.unwrap_or(self.recipe.min_width),
99            recipe: self.recipe,
100        })
101    }
102
103    fn make_scrim(&self, ctx: &mut BuildContext) -> WidgetId {
104        ctx.add(RectWidget::new().background(SurfaceRole::Scrim))
105    }
106}
107
108/// Internal container that paints the modal panel chrome (rounded
109/// `surface_main` fill + `border_strong` stroke) and positions the
110/// content with the dialog padding inset. Mirrors the pre-migration
111/// `ModalContainer` layout exactly (single widget so proposal-resolve
112/// and the `min_width` clamp behave identically).
113struct DialogPanel {
114    child_id: Option<WidgetId>,
115    pending_child: Option<PendingChild>,
116    padding: f32,
117    min_width: f32,
118    recipe: DialogRecipe,
119}
120
121impl std::fmt::Debug for DialogPanel {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.debug_struct("DialogPanel")
124            .field("padding", &self.padding)
125            .field("min_width", &self.min_width)
126            .finish()
127    }
128}
129
130impl Widget for DialogPanel {
131    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
132        if let Some(pending) = self.pending_child.take() {
133            self.child_id = Some(match pending {
134                PendingChild::Id(id) => id,
135                PendingChild::Deferred(w) => ctx.add_boxed(w),
136            });
137        }
138        self.child_id.into_iter().collect()
139    }
140
141    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
142        let inset = self.padding * 2.0;
143        let content = self
144            .child_id
145            .and_then(|id| {
146                ctx.child_size(
147                    id,
148                    SizeProposal {
149                        width: proposal.width.map(|width| (width - inset).max(0.0)),
150                        height: proposal.height.map(|height| (height - inset).max(0.0)),
151                    },
152                )
153            })
154            .unwrap_or_else(|| proposal.resolve(240.0, 120.0));
155
156        Size::new(
157            (content.width + inset).max(self.min_width),
158            content.height + inset,
159        )
160        .into()
161    }
162
163    fn place_children(
164        &self,
165        bounds: Rect,
166        _proposal: SizeProposal,
167        children: &mut [WidgetPlacement],
168        _ctx: &LayoutContext,
169    ) {
170        let pad = self.padding;
171        for child in children.iter_mut() {
172            child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
173            child.size = Size::new(
174                (bounds.width - pad * 2.0).max(0.0),
175                (bounds.height - pad * 2.0).max(0.0),
176            );
177        }
178    }
179
180    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
181        let radius = CornerRadius::uniform(self.recipe.corner_radius);
182        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.surface_main);
183        canvas.stroke_rounded_rect(
184            bounds,
185            radius,
186            ctx.theme.colors.border_strong,
187            ctx.theme.shape.border_width,
188        );
189    }
190
191    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
192        // Presentational: the parent `ModalContainer` emits the modal
193        // `Role::Dialog` node and its name. A bare `GenericContainer` is how
194        // chrome says so. The walker prunes it and promotes the content, and
195        // the consumer every adapter reads through drops such a node while
196        // keeping its children (`ExcludeNode`). Never `set_hidden()`: the
197        // consumer reads a hidden node as `ExcludeSubtree`, and every field,
198        // button and line of text in the dialog went with the panel, leaving
199        // a reader only the focused control, orphaned from the dialog.
200        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
201    }
202
203    fn children(&self) -> Vec<WidgetId> {
204        self.child_id.into_iter().collect()
205    }
206}