Skip to main content

teksilo_widgets/
popover_surface.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `PopoverSurface` — the themed panel a popover's content sits in.
5//!
6//! Style infrastructure, not a widget an app mounts: `RecipePopoverStyle` (and
7//! any `PopoverStyle` replacing it) constructs one in `make_body`, and
8//! `PopoverWidget` shows the result as its overlay. It lived in `popover.rs`
9//! beside the standalone `Popover` widget until that type was removed; the two
10//! were never related beyond sharing a file.
11
12use teksilo_canvas::{Canvas, EdgeInsets, Path, Point, Rect, Size, SizeProposal};
13use teksilo_core::accessibility::AccessNodeBuilder;
14use teksilo_core::build_context::BuildContext;
15use teksilo_core::overlay::OverlayPlacement;
16use teksilo_core::widget::{LayoutContext, PaintContext, PendingChild, Widget, WidgetPlacement};
17use teksilo_core::widget_id::WidgetId;
18use teksilo_tokens::{CornerRadius, SurfaceRole};
19
20pub struct PopoverSurface {
21    content_id: Option<WidgetId>,
22    pending_content: Option<PendingChild>,
23    placement: OverlayPlacement,
24    show_caret: bool,
25    caret_size: f32,
26    /// Accessible name for the dialog node — propagated from the trigger label.
27    name: String,
28    /// Inset between the panel edge and the wrapped content. Defaulted
29    /// per `PopoverVariant` by `RecipePopoverStyle` (16 px for
30    /// Default/Tooltip, zero for Menu so menu rows reach the edge).
31    content_padding: EdgeInsets,
32    /// Surface fill role for the panel background + caret.
33    background: SurfaceRole,
34    /// Panel corner radius in logical pixels.
35    corner_radius: f32,
36    /// When true the surface emits no semantic node (`set_hidden`) —
37    /// used by the Menu variant, where the caller (`MenuList`,
38    /// `DropdownPanel`, `SuggestionListBox`) already carries the
39    /// container role. Default/Tooltip surfaces emit `Role::Dialog`.
40    presentational: bool,
41}
42
43impl PopoverSurface {
44    #[allow(clippy::too_many_arguments)]
45    pub fn new(
46        content: PendingChild,
47        placement: OverlayPlacement,
48        show_caret: bool,
49        caret_size: f32,
50        name: String,
51        content_padding: EdgeInsets,
52        background: SurfaceRole,
53        corner_radius: f32,
54        presentational: bool,
55    ) -> Self {
56        Self {
57            content_id: None,
58            pending_content: Some(content),
59            placement,
60            show_caret,
61            caret_size,
62            name,
63            content_padding,
64            background,
65            corner_radius,
66            presentational,
67        }
68    }
69
70    /// Which side of the panel rect is attached to the trigger and
71    /// should suppress shadow drawing. Derived from `placement` plus
72    /// the active layout direction (resolved at paint time):
73    /// - `Below*` / `NearAnchor` → anchor sits above ⇒ Top.
74    /// - `Above` → anchor sits below ⇒ Bottom.
75    /// - `TrailingEdge` → anchor sits on the leading side ⇒ Left in
76    ///   LTR, Right in RTL.
77    /// - Anything else (Centered, AtPointer, BottomCenter) → not
78    ///   visually attached ⇒ no suppression.
79    fn attached_shadow_side(
80        &self,
81        layout_direction: teksilo_core::environment::LayoutDirection,
82    ) -> Option<crate::shadow::AttachedSide> {
83        use teksilo_core::environment::LayoutDirection;
84        match self.placement {
85            OverlayPlacement::Below
86            | OverlayPlacement::BelowPreferred
87            | OverlayPlacement::NearAnchor { .. } => Some(crate::shadow::AttachedSide::Top),
88            OverlayPlacement::Above => Some(crate::shadow::AttachedSide::Bottom),
89            OverlayPlacement::TrailingEdge => match layout_direction {
90                LayoutDirection::LeftToRight => Some(crate::shadow::AttachedSide::Left),
91                LayoutDirection::RightToLeft => Some(crate::shadow::AttachedSide::Right),
92            },
93            _ => None,
94        }
95    }
96
97    fn caret_insets(&self) -> (f32, f32) {
98        if !self.show_caret {
99            return (0.0, 0.0);
100        }
101
102        match self.placement {
103            OverlayPlacement::Below
104            | OverlayPlacement::BelowPreferred
105            | OverlayPlacement::NearAnchor { .. } => (self.caret_size, 0.0),
106            OverlayPlacement::Above => (0.0, self.caret_size),
107            _ => (0.0, 0.0),
108        }
109    }
110
111    fn panel_bounds(&self, bounds: Rect) -> Rect {
112        let (top, bottom) = self.caret_insets();
113        Rect::new(
114            bounds.x,
115            bounds.y + top,
116            bounds.width,
117            (bounds.height - top - bottom).max(0.0),
118        )
119    }
120
121    fn caret_path(&self, bounds: Rect) -> Option<Path> {
122        if !self.show_caret {
123            return None;
124        }
125
126        let panel = self.panel_bounds(bounds);
127        let center_x = panel.x + panel.width.min(56.0) / 2.0 + 18.0;
128        let half = self.caret_size;
129        let mut path = Path::new();
130
131        match self.placement {
132            OverlayPlacement::Below
133            | OverlayPlacement::BelowPreferred
134            | OverlayPlacement::NearAnchor { .. } => {
135                path.move_to(Point::new(center_x - half, panel.y));
136                path.line_to(Point::new(center_x, bounds.y));
137                path.line_to(Point::new(center_x + half, panel.y));
138                path.close();
139                Some(path)
140            }
141            OverlayPlacement::Above => {
142                let bottom = panel.bottom();
143                path.move_to(Point::new(center_x - half, bottom));
144                path.line_to(Point::new(center_x, bottom + self.caret_size));
145                path.line_to(Point::new(center_x + half, bottom));
146                path.close();
147                Some(path)
148            }
149            _ => None,
150        }
151    }
152}
153
154impl std::fmt::Debug for PopoverSurface {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("PopoverSurface").finish()
157    }
158}
159
160impl Widget for PopoverSurface {
161    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
162        if let Some(pending) = self.pending_content.take() {
163            self.content_id = Some(match pending {
164                PendingChild::Id(id) => id,
165                PendingChild::Deferred(w) => ctx.add_boxed(w),
166            });
167        }
168        self.children()
169    }
170
171    fn layout_response(
172        &self,
173        proposal: SizeProposal,
174        ctx: &LayoutContext,
175    ) -> teksilo_core::widget::LayoutResponse {
176        let inset_w = self.content_padding.leading + self.content_padding.trailing;
177        let inset_h = self.content_padding.top + self.content_padding.bottom;
178        let (caret_top, caret_bottom) = self.caret_insets();
179        self.content_id
180            .and_then(|id| {
181                ctx.child_size(
182                    id,
183                    SizeProposal {
184                        width: proposal.width.map(|width| (width - inset_w).max(0.0)),
185                        height: proposal
186                            .height
187                            .map(|height| (height - inset_h - caret_top - caret_bottom).max(0.0)),
188                    },
189                )
190            })
191            .map(|size| {
192                Size::new(
193                    size.width + inset_w,
194                    size.height + inset_h + caret_top + caret_bottom,
195                )
196            })
197            .unwrap_or_else(|| proposal.resolve(200.0, 80.0))
198            .into()
199    }
200
201    fn place_children(
202        &self,
203        bounds: Rect,
204        _proposal: SizeProposal,
205        children: &mut [WidgetPlacement],
206        _ctx: &LayoutContext,
207    ) {
208        let panel = self.panel_bounds(bounds);
209        let pad = self.content_padding;
210        for child in children.iter_mut() {
211            child.origin = teksilo_canvas::Point::new(panel.x + pad.leading, panel.y + pad.top);
212            child.size = Size::new(
213                (panel.width - pad.leading - pad.trailing).max(0.0),
214                (panel.height - pad.top - pad.bottom).max(0.0),
215            );
216        }
217    }
218
219    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
220        let panel = self.panel_bounds(bounds);
221        let radius = CornerRadius::uniform(self.corner_radius);
222        let fill = self.background.resolve(&ctx.theme.colors);
223        crate::shadow::paint_layered_shadow(
224            canvas,
225            panel,
226            radius,
227            &ctx.theme.shape.shadow_sm,
228            &ctx.theme.shape.shadow_inner_sm,
229            crate::styles::recipe_popover_style::POPOVER_SHADOW_DENSITY,
230            self.attached_shadow_side(ctx.layout_direction),
231        );
232        // The caret extends into the just-suppressed zone (between
233        // panel and trigger). It's painted unshaded below — that's
234        // intentional, the caret reads as part of the trigger-attach
235        // region, not as a separate elevated surface.
236        canvas.fill_rounded_rect(panel, radius, fill);
237        canvas.stroke_rounded_rect(
238            panel,
239            radius,
240            ctx.theme.colors.border,
241            ctx.theme.shape.border_width,
242        );
243        if let Some(path) = self.caret_path(bounds) {
244            canvas.fill_path(&path, fill);
245            canvas.stroke_path(&path, ctx.theme.colors.border, ctx.theme.shape.border_width);
246        }
247    }
248
249    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
250        if self.presentational {
251            // Menu-variant container: the caller (`MenuList`,
252            // `DropdownPanel`, `SuggestionListBox`) already owns the
253            // semantic role, so the surface contributes nothing.
254            builder.set_hidden();
255            return;
256        }
257        // Popover surface is modeled as a non-modal Dialog: ARIA has
258        // no dedicated popover role, and Role::Dialog without
259        // `set_modal` is the standard fallback for panels that float
260        // over other content without blocking it. Every dialog node
261        // must have an accessible name; use the trigger's label.
262        builder.set_role(teksilo_core::accesskit::Role::Dialog);
263        builder.set_name(&self.name);
264    }
265
266    fn children(&self) -> Vec<WidgetId> {
267        self.content_id.into_iter().collect()
268    }
269}