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 of its own (a bare
37    /// `GenericContainer`, dropped with its content kept): used by the
38    /// Menu variant, where the caller (`MenuList`, `DropdownPanel`,
39    /// `SuggestionListBox`) already carries the container role.
40    /// Default/Tooltip surfaces emit `Role::Dialog`.
41    presentational: bool,
42}
43
44impl PopoverSurface {
45    #[allow(clippy::too_many_arguments)]
46    pub fn new(
47        content: PendingChild,
48        placement: OverlayPlacement,
49        show_caret: bool,
50        caret_size: f32,
51        name: String,
52        content_padding: EdgeInsets,
53        background: SurfaceRole,
54        corner_radius: f32,
55        presentational: bool,
56    ) -> Self {
57        Self {
58            content_id: None,
59            pending_content: Some(content),
60            placement,
61            show_caret,
62            caret_size,
63            name,
64            content_padding,
65            background,
66            corner_radius,
67            presentational,
68        }
69    }
70
71    /// Which side of the panel rect is attached to the trigger and
72    /// should suppress shadow drawing. Derived from `placement` plus
73    /// the active layout direction (resolved at paint time):
74    /// - `Below*` / `NearAnchor` → anchor sits above ⇒ Top.
75    /// - `Above` → anchor sits below ⇒ Bottom.
76    /// - `TrailingEdge` → anchor sits on the leading side ⇒ Left in
77    ///   LTR, Right in RTL.
78    /// - Anything else (Centered, AtPointer, BottomCenter) → not
79    ///   visually attached ⇒ no suppression.
80    fn attached_shadow_side(
81        &self,
82        layout_direction: teksilo_core::environment::LayoutDirection,
83    ) -> Option<crate::shadow::AttachedSide> {
84        use teksilo_core::environment::LayoutDirection;
85        match self.placement {
86            OverlayPlacement::Below
87            | OverlayPlacement::BelowPreferred
88            | OverlayPlacement::NearAnchor { .. } => Some(crate::shadow::AttachedSide::Top),
89            OverlayPlacement::Above => Some(crate::shadow::AttachedSide::Bottom),
90            OverlayPlacement::TrailingEdge => match layout_direction {
91                LayoutDirection::LeftToRight => Some(crate::shadow::AttachedSide::Left),
92                LayoutDirection::RightToLeft => Some(crate::shadow::AttachedSide::Right),
93            },
94            _ => None,
95        }
96    }
97
98    fn caret_insets(&self) -> (f32, f32) {
99        if !self.show_caret {
100            return (0.0, 0.0);
101        }
102
103        match self.placement {
104            OverlayPlacement::Below
105            | OverlayPlacement::BelowPreferred
106            | OverlayPlacement::NearAnchor { .. } => (self.caret_size, 0.0),
107            OverlayPlacement::Above => (0.0, self.caret_size),
108            _ => (0.0, 0.0),
109        }
110    }
111
112    fn panel_bounds(&self, bounds: Rect) -> Rect {
113        let (top, bottom) = self.caret_insets();
114        Rect::new(
115            bounds.x,
116            bounds.y + top,
117            bounds.width,
118            (bounds.height - top - bottom).max(0.0),
119        )
120    }
121
122    fn caret_path(&self, bounds: Rect) -> Option<Path> {
123        if !self.show_caret {
124            return None;
125        }
126
127        let panel = self.panel_bounds(bounds);
128        let center_x = panel.x + panel.width.min(56.0) / 2.0 + 18.0;
129        let half = self.caret_size;
130        let mut path = Path::new();
131
132        match self.placement {
133            OverlayPlacement::Below
134            | OverlayPlacement::BelowPreferred
135            | OverlayPlacement::NearAnchor { .. } => {
136                path.move_to(Point::new(center_x - half, panel.y));
137                path.line_to(Point::new(center_x, bounds.y));
138                path.line_to(Point::new(center_x + half, panel.y));
139                path.close();
140                Some(path)
141            }
142            OverlayPlacement::Above => {
143                let bottom = panel.bottom();
144                path.move_to(Point::new(center_x - half, bottom));
145                path.line_to(Point::new(center_x, bottom + self.caret_size));
146                path.line_to(Point::new(center_x + half, bottom));
147                path.close();
148                Some(path)
149            }
150            _ => None,
151        }
152    }
153}
154
155impl std::fmt::Debug for PopoverSurface {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        f.debug_struct("PopoverSurface").finish()
158    }
159}
160
161impl Widget for PopoverSurface {
162    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
163        if let Some(pending) = self.pending_content.take() {
164            self.content_id = Some(match pending {
165                PendingChild::Id(id) => id,
166                PendingChild::Deferred(w) => ctx.add_boxed(w),
167            });
168        }
169        self.children()
170    }
171
172    fn layout_response(
173        &self,
174        proposal: SizeProposal,
175        ctx: &LayoutContext,
176    ) -> teksilo_core::widget::LayoutResponse {
177        let inset_w = self.content_padding.leading + self.content_padding.trailing;
178        let inset_h = self.content_padding.top + self.content_padding.bottom;
179        let (caret_top, caret_bottom) = self.caret_insets();
180        self.content_id
181            .and_then(|id| {
182                ctx.child_size(
183                    id,
184                    SizeProposal {
185                        width: proposal.width.map(|width| (width - inset_w).max(0.0)),
186                        height: proposal
187                            .height
188                            .map(|height| (height - inset_h - caret_top - caret_bottom).max(0.0)),
189                    },
190                )
191            })
192            .map(|size| {
193                Size::new(
194                    size.width + inset_w,
195                    size.height + inset_h + caret_top + caret_bottom,
196                )
197            })
198            .unwrap_or_else(|| proposal.resolve(200.0, 80.0))
199            .into()
200    }
201
202    fn place_children(
203        &self,
204        bounds: Rect,
205        _proposal: SizeProposal,
206        children: &mut [WidgetPlacement],
207        _ctx: &LayoutContext,
208    ) {
209        let panel = self.panel_bounds(bounds);
210        let pad = self.content_padding;
211        for child in children.iter_mut() {
212            child.origin = teksilo_canvas::Point::new(panel.x + pad.leading, panel.y + pad.top);
213            child.size = Size::new(
214                (panel.width - pad.leading - pad.trailing).max(0.0),
215                (panel.height - pad.top - pad.bottom).max(0.0),
216            );
217        }
218    }
219
220    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
221        let panel = self.panel_bounds(bounds);
222        let radius = CornerRadius::uniform(self.corner_radius);
223        let fill = self.background.resolve(&ctx.theme.colors);
224        crate::shadow::paint_layered_shadow(
225            canvas,
226            panel,
227            radius,
228            &ctx.theme.shape.shadow_sm,
229            &ctx.theme.shape.shadow_inner_sm,
230            crate::styles::recipe_popover_style::POPOVER_SHADOW_DENSITY,
231            self.attached_shadow_side(ctx.layout_direction),
232        );
233        // The caret extends into the just-suppressed zone (between
234        // panel and trigger). It's painted unshaded below — that's
235        // intentional, the caret reads as part of the trigger-attach
236        // region, not as a separate elevated surface.
237        canvas.fill_rounded_rect(panel, radius, fill);
238        canvas.stroke_rounded_rect(
239            panel,
240            radius,
241            ctx.theme.colors.border,
242            ctx.theme.shape.border_width,
243        );
244        if let Some(path) = self.caret_path(bounds) {
245            canvas.fill_path(&path, fill);
246            canvas.stroke_path(&path, ctx.theme.colors.border, ctx.theme.shape.border_width);
247        }
248    }
249
250    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
251        if self.presentational {
252            // Menu-variant container: the caller (`MenuList`,
253            // `DropdownPanel`, `SuggestionListBox`) already owns the
254            // semantic role, so the surface contributes nothing. A bare
255            // `GenericContainer` is dropped by the walker and by every
256            // adapter with its children kept. `set_hidden()` took the menu
257            // items with it, so a menu listed no items and only the one
258            // under the cursor could be heard.
259            builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
260            return;
261        }
262        // Popover surface is modeled as a non-modal Dialog: ARIA has
263        // no dedicated popover role, and Role::Dialog without
264        // `set_modal` is the standard fallback for panels that float
265        // over other content without blocking it. Every dialog node
266        // must have an accessible name; use the trigger's label.
267        builder.set_role(teksilo_core::accesskit::Role::Dialog);
268        // An empty name is still a name to every adapter, and it would hide
269        // the `labelled_by` relation `PopoverWidget` names the surface with
270        // when its caller gave no name (`accesskit_consumer` reads the
271        // relation only for a node with no label of its own).
272        if !self.name.is_empty() {
273            builder.set_name(&self.name);
274        }
275    }
276
277    fn children(&self) -> Vec<WidgetId> {
278        self.content_id.into_iter().collect()
279    }
280}