Skip to main content

teksilo_widgets/styles/
recipe_drop_target_style.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Default `DropTargetStyle` impl driven by paint-recipe data.
5//!
6//! `RecipeDropTargetStyle` ships the IntUI drop-target chrome: the wrapped
7//! child fills the bounds and stays fully visible; a full-bleed `RectWidget`
8//! strokes a reactive rounded **whole-bounds** border (error on reject, accent
9//! on a `Center` accept — no fill, an opaque tint would hide the child); a
10//! `DropRegionOverlay` paints the active **side** zone's highlight (an edge
11//! strip → translucent fill + accent frame) and hosts the per-region hint cards;
12//! and each hint is a popup `Card` centered within its region's rect, shown only
13//! while that zone is the active accepted-hover.
14//!
15//! The overlay layout (a `ZStack` of child + reject-rect + region-overlay) never
16//! inflates the stack's intrinsic size: `RectWidget` and `DropRegionOverlay`
17//! report 0×0 for an unspecified proposal and fill an exact one, so the target
18//! sizes to exactly the wrapped child. The `DropTarget` widget sets
19//! `clips_children`, keeping an oversized hint card inside its zone.
20//!
21//! Each hint is gated with [`BuildContext::visible_when`] on a derived
22//! "is *this* region the active accepted-hover?" signal: it culls both paint
23//! **and** the accessibility node when its zone isn't active, so a screen reader
24//! never meets an inactive zone's prompt. `Live::Polite` on the card announces
25//! it appearing.
26//!
27//! The decorative chrome (the reject-border `RectWidget` and, when the target
28//! declares no hints, the `DropRegionOverlay`) is **hidden from the AT tree** —
29//! a hint-less multi-zone target (e.g. a docking pane) adds no empty container
30//! per drop target.
31//!
32//! Apps wanting a different look (dashed border, translucent wash, glow, no
33//! popup) write their own `impl DropTargetStyle` block and install it per-call
34//! (`DropTarget::style(...)`) or theme-wide
35//! (`theme.style_slots.drop_target = Some(Rc::new(...))`). The
36//! [`DropTargetDragState::surface_role`] helper is there for styles that do
37//! want a (translucent) fill.
38
39use teksilo_core::accesskit::Live;
40use teksilo_core::build_context::BuildContext;
41use teksilo_core::styles::{
42    DropRegion, DropTargetDragState, DropTargetStyle, DropTargetStyleConfig, DropTargetVariant,
43};
44use teksilo_core::widget_builder::WidgetBuilder;
45use teksilo_core::widget_id::WidgetId;
46use teksilo_tokens::{BorderRole, CornerRadius};
47
48use crate::card::Card;
49use crate::drop_target::overlay::DropRegionOverlay;
50use crate::primitives::{RectWidget, ZStack};
51
52/// Corner radius of the overlay's rounded border.
53pub const DROP_TARGET_CORNER_RADIUS: f32 = 8.0;
54/// Border thickness for the `Default` variant.
55pub const DROP_TARGET_BORDER_WIDTH_DEFAULT: f32 = 2.0;
56/// Border thickness for the `Prominent` variant.
57pub const DROP_TARGET_BORDER_WIDTH_PROMINENT: f32 = 3.0;
58/// Border thickness for the `Subtle` variant.
59pub const DROP_TARGET_BORDER_WIDTH_SUBTLE: f32 = 1.0;
60
61/// Configurable dimensions for [`RecipeDropTargetStyle`].
62#[derive(Debug, Clone, Copy, PartialEq)]
63pub struct DropTargetRecipe {
64    /// Corner radius of the overlay's rounded border.
65    pub corner_radius: f32,
66    /// Border thickness for the `Default` variant.
67    pub border_width_default: f32,
68    /// Border thickness for the `Prominent` variant.
69    pub border_width_prominent: f32,
70    /// Border thickness for the `Subtle` variant.
71    pub border_width_subtle: f32,
72}
73
74impl Default for DropTargetRecipe {
75    fn default() -> Self {
76        Self {
77            corner_radius: DROP_TARGET_CORNER_RADIUS,
78            border_width_default: DROP_TARGET_BORDER_WIDTH_DEFAULT,
79            border_width_prominent: DROP_TARGET_BORDER_WIDTH_PROMINENT,
80            border_width_subtle: DROP_TARGET_BORDER_WIDTH_SUBTLE,
81        }
82    }
83}
84
85/// Default `DropTargetStyle` shipped with Teksilo.
86#[derive(Debug, Default, Clone, Copy)]
87pub struct RecipeDropTargetStyle {
88    /// Tunable dimensions for this style instance.
89    pub recipe: DropTargetRecipe,
90}
91
92impl RecipeDropTargetStyle {
93    /// Create a style with custom recipe dimensions.
94    pub fn new(recipe: DropTargetRecipe) -> Self {
95        Self { recipe }
96    }
97}
98
99impl DropTargetStyle for RecipeDropTargetStyle {
100    fn make_body(&self, cfg: &DropTargetStyleConfig, ctx: &mut BuildContext) -> WidgetId {
101        let border_width = match cfg.variant {
102            DropTargetVariant::Default => self.recipe.border_width_default,
103            DropTargetVariant::Prominent => self.recipe.border_width_prominent,
104            DropTargetVariant::Subtle => self.recipe.border_width_subtle,
105            DropTargetVariant::None => 0.0,
106        };
107
108        // The wrapped child fills the bounds and is always visible.
109        let mut zstack = ZStack::new().add_child(cfg.content_id);
110
111        // Full-bounds rounded border for the whole-bounds states: a reject error
112        // border, and the `Center` accept border (so single-zone accept keeps its
113        // rounded corners — the overlay paints only the *side* zones). Side-zone
114        // accept and idle leave it transparent. Only a stroke — no fill — so the
115        // child is never hidden, and `event_pass_through` so this decorative
116        // overlay never steals pointer events from the wrapped content. Skipped
117        // for `None`.
118        if cfg.variant != DropTargetVariant::None {
119            let border = cfg
120                .drag_state
121                .zip(&cfg.active_region)
122                .map(|(s, r)| match s {
123                    DropTargetDragState::HoverReject => BorderRole::Error,
124                    DropTargetDragState::HoverAccept if *r == Some(DropRegion::Center) => {
125                        BorderRole::Accent
126                    }
127                    _ => BorderRole::Transparent,
128                });
129            let rect = ctx.add(
130                RectWidget::new()
131                    .border_color(border)
132                    .border_width(border_width)
133                    .corner_radius(CornerRadius::uniform(self.recipe.corner_radius))
134                    .event_pass_through(true)
135                    // Decorative highlight border — keep it out of the AT tree.
136                    .access_hidden(true),
137            );
138            zstack = zstack.add_child(rect);
139        }
140
141        // Per-region hint cards, each shown only while *its* region is the
142        // active accepted-hover. `visible_when` culls both paint and the AT
143        // node when a region isn't active; `Live::Polite` announces the hint
144        // *appearing*. The cards are hosted (and placed inside their region
145        // rect) by the `DropRegionOverlay` below — so we pass their ids on.
146        let mut hint_cards: Vec<(DropRegion, WidgetId)> = Vec::new();
147        for &(region, hint_id) in &cfg.region_hints {
148            let card = ctx.add(Card::new().content_id(hint_id).access_live(Live::Polite));
149            let visible = cfg.active_region.map(move |r| *r == Some(region));
150            ctx.visible_when(card, visible);
151            hint_cards.push((region, card));
152        }
153
154        // The reactive zone highlight + hint host. It paints the active region's
155        // affordance (frame over the child — `event_pass_through`, so it never
156        // steals pointer events from the wrapped interactive content) and places
157        // each hint card centered within its zone. Skipped entirely only when
158        // there's nothing for it to do (no border and no hints).
159        if border_width > 0.0 || !hint_cards.is_empty() {
160            let overlay = ctx.add(DropRegionOverlay::new(
161                cfg.active_region.clone(),
162                cfg.size_factor,
163                border_width,
164                hint_cards,
165            ));
166            zstack = zstack.add_child(overlay);
167        }
168
169        ctx.add(zstack)
170    }
171}