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, SurfaceRole};
27
28use crate::primitives::RectWidget;
29
30// IntUI design tokens for Dialog. The recipe owns its own dimensions.
31pub const DIALOG_CONTENT_PADDING: f32 = 24.0;
32pub const DIALOG_MIN_WIDTH: f32 = 280.0;
33pub const DIALOG_CORNER_RADIUS: f32 = 8.0;
34
35/// Dimension bundle for [`RecipeDialogStyle`]. All fields have defaults
36/// driven by the module-level `DIALOG_*` consts.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct DialogRecipe {
39    pub content_padding: f32,
40    pub min_width: f32,
41    pub corner_radius: f32,
42}
43
44impl Default for DialogRecipe {
45    fn default() -> Self {
46        Self {
47            content_padding: DIALOG_CONTENT_PADDING,
48            min_width: DIALOG_MIN_WIDTH,
49            corner_radius: DIALOG_CORNER_RADIUS,
50        }
51    }
52}
53
54/// Default `DialogStyle` shipped with Teksilo. Panel chrome is the
55/// rounded `surface_main` surface + `border_strong` stroke; the scrim
56/// is a plain `SurfaceRole::Scrim` fill.
57#[derive(Debug, Default, Clone, Copy)]
58pub struct RecipeDialogStyle {
59    pub recipe: DialogRecipe,
60}
61
62impl RecipeDialogStyle {
63    pub fn new(recipe: DialogRecipe) -> Self {
64        Self { recipe }
65    }
66}
67
68impl DialogStyle for RecipeDialogStyle {
69    fn make_panel(&self, cfg: &DialogStyleConfig, ctx: &mut BuildContext) -> WidgetId {
70        ctx.add(DialogPanel {
71            child_id: None,
72            pending_child: Some(PendingChild::Id(cfg.content)),
73            padding: cfg.padding_override.unwrap_or(self.recipe.content_padding),
74            min_width: cfg.min_width_override.unwrap_or(self.recipe.min_width),
75            recipe: self.recipe,
76        })
77    }
78
79    fn make_scrim(&self, ctx: &mut BuildContext) -> WidgetId {
80        ctx.add(RectWidget::new().background(SurfaceRole::Scrim))
81    }
82}
83
84/// Internal container that paints the modal panel chrome (rounded
85/// `surface_main` fill + `border_strong` stroke) and positions the
86/// content with the dialog padding inset. Mirrors the pre-migration
87/// `ModalContainer` layout exactly (single widget so proposal-resolve
88/// and the `min_width` clamp behave identically).
89struct DialogPanel {
90    child_id: Option<WidgetId>,
91    pending_child: Option<PendingChild>,
92    padding: f32,
93    min_width: f32,
94    recipe: DialogRecipe,
95}
96
97impl std::fmt::Debug for DialogPanel {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_struct("DialogPanel")
100            .field("padding", &self.padding)
101            .field("min_width", &self.min_width)
102            .finish()
103    }
104}
105
106impl Widget for DialogPanel {
107    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
108        if let Some(pending) = self.pending_child.take() {
109            self.child_id = Some(match pending {
110                PendingChild::Id(id) => id,
111                PendingChild::Deferred(w) => ctx.add_boxed(w),
112            });
113        }
114        self.child_id.into_iter().collect()
115    }
116
117    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
118        let inset = self.padding * 2.0;
119        let content = self
120            .child_id
121            .and_then(|id| {
122                ctx.child_size(
123                    id,
124                    SizeProposal {
125                        width: proposal.width.map(|width| (width - inset).max(0.0)),
126                        height: proposal.height.map(|height| (height - inset).max(0.0)),
127                    },
128                )
129            })
130            .unwrap_or_else(|| proposal.resolve(240.0, 120.0));
131
132        Size::new(
133            (content.width + inset).max(self.min_width),
134            content.height + inset,
135        )
136        .into()
137    }
138
139    fn place_children(
140        &self,
141        bounds: Rect,
142        _proposal: SizeProposal,
143        children: &mut [WidgetPlacement],
144        _ctx: &LayoutContext,
145    ) {
146        let pad = self.padding;
147        for child in children.iter_mut() {
148            child.origin = teksilo_canvas::Point::new(bounds.x + pad, bounds.y + pad);
149            child.size = Size::new(
150                (bounds.width - pad * 2.0).max(0.0),
151                (bounds.height - pad * 2.0).max(0.0),
152            );
153        }
154    }
155
156    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
157        let radius = CornerRadius::uniform(self.recipe.corner_radius);
158        canvas.fill_rounded_rect(bounds, radius, ctx.theme.colors.surface_main);
159        canvas.stroke_rounded_rect(
160            bounds,
161            radius,
162            ctx.theme.colors.border_strong,
163            ctx.theme.shape.border_width,
164        );
165    }
166
167    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
168        // Presentational — the parent `ModalContainer` emits the modal
169        // `Role::Dialog` node with the accessible name.
170        builder.set_hidden();
171    }
172
173    fn children(&self) -> Vec<WidgetId> {
174        self.child_id.into_iter().collect()
175    }
176}