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, InputTokens};
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 DropTargetRecipe {
75    /// This recipe's dimensions resolved against a density's [`InputTokens`].
76    ///
77    /// [`Default`] is `for_tokens(&InputTokens::default())` — the Compact
78    /// ladder — so the shipped values below are the Compact column by
79    /// construction and cannot drift from it.
80    ///
81    /// Every dimension this recipe carries is a decoration (a corner radius, a
82    /// hairline, a glyph metric), so the parameter is unused: the density
83    /// ladder never moves any of them. It is taken all the same, so every
84    /// recipe is constructed the same way at its widget's build site.
85    pub fn for_tokens(_tokens: &InputTokens) -> Self {
86        Self {
87            corner_radius: DROP_TARGET_CORNER_RADIUS,
88            border_width_default: DROP_TARGET_BORDER_WIDTH_DEFAULT,
89            border_width_prominent: DROP_TARGET_BORDER_WIDTH_PROMINENT,
90            border_width_subtle: DROP_TARGET_BORDER_WIDTH_SUBTLE,
91        }
92    }
93}
94
95impl Default for DropTargetRecipe {
96    fn default() -> Self {
97        Self::for_tokens(&InputTokens::default())
98    }
99}
100
101/// Default `DropTargetStyle` shipped with Teksilo.
102#[derive(Debug, Default, Clone, Copy)]
103pub struct RecipeDropTargetStyle {
104    /// Tunable dimensions for this style instance.
105    pub recipe: DropTargetRecipe,
106}
107
108impl RecipeDropTargetStyle {
109    /// Create a style with custom recipe dimensions.
110    pub fn new(recipe: DropTargetRecipe) -> Self {
111        Self { recipe }
112    }
113
114    /// This style with every dimension resolved against a density's
115    /// [`InputTokens`], as `RecipeDropTargetStyle::for_tokens(&ctx.theme().input)` at
116    /// the widget's own build site.
117    ///
118    /// [`Default`] is the `TargetDensity::Compact` projection, so a Compact
119    /// tree gets exactly the values this module documents.
120    pub fn for_tokens(tokens: &InputTokens) -> Self {
121        Self {
122            recipe: DropTargetRecipe::for_tokens(tokens),
123        }
124    }
125}
126
127impl DropTargetStyle for RecipeDropTargetStyle {
128    fn make_body(&self, cfg: &DropTargetStyleConfig, ctx: &mut BuildContext) -> WidgetId {
129        let border_width = match cfg.variant {
130            DropTargetVariant::Default => self.recipe.border_width_default,
131            DropTargetVariant::Prominent => self.recipe.border_width_prominent,
132            DropTargetVariant::Subtle => self.recipe.border_width_subtle,
133            DropTargetVariant::None => 0.0,
134        };
135
136        // The wrapped child fills the bounds and is always visible.
137        let mut zstack = ZStack::new().child(cfg.content_id);
138
139        // Full-bounds rounded border for the whole-bounds states: a reject error
140        // border, and the `Center` accept border (so single-zone accept keeps its
141        // rounded corners — the overlay paints only the *side* zones). Side-zone
142        // accept and idle leave it transparent. Only a stroke — no fill — so the
143        // child is never hidden, and `event_pass_through` so this decorative
144        // overlay never steals pointer events from the wrapped content. Skipped
145        // for `None`.
146        if cfg.variant != DropTargetVariant::None {
147            let border = cfg
148                .drag_state
149                .zip(&cfg.active_region)
150                .map(|(s, r)| match s {
151                    DropTargetDragState::HoverReject => BorderRole::Error,
152                    DropTargetDragState::HoverAccept if *r == Some(DropRegion::Center) => {
153                        BorderRole::Accent
154                    }
155                    _ => BorderRole::Transparent,
156                });
157            let rect = ctx.add(
158                RectWidget::new()
159                    .border_color(border)
160                    .border_width(border_width)
161                    .corner_radius(CornerRadius::uniform(self.recipe.corner_radius))
162                    .event_pass_through(true)
163                    // Decorative highlight border — keep it out of the AT tree.
164                    .access_hidden(true),
165            );
166            zstack = zstack.child(rect);
167        }
168
169        // Per-region hint cards, each shown only while *its* region is the
170        // active accepted-hover. `visible_when` culls both paint and the AT
171        // node when a region isn't active; `Live::Polite` announces the hint
172        // *appearing*. The cards are hosted (and placed inside their region
173        // rect) by the `DropRegionOverlay` below — so we pass their ids on.
174        let mut hint_cards: Vec<(DropRegion, WidgetId)> = Vec::new();
175        for &(region, hint_id) in &cfg.region_hints {
176            let card = ctx.add(Card::new().content(hint_id).access_live(Live::Polite));
177            let visible = cfg.active_region.map(move |r| *r == Some(region));
178            ctx.visible_when(card, visible);
179            hint_cards.push((region, card));
180        }
181
182        // The reactive zone highlight + hint host. It paints the active region's
183        // affordance (frame over the child — `event_pass_through`, so it never
184        // steals pointer events from the wrapped interactive content) and places
185        // each hint card centered within its zone. Skipped entirely only when
186        // there's nothing for it to do (no border and no hints).
187        if border_width > 0.0 || !hint_cards.is_empty() {
188            let overlay = ctx.add(DropRegionOverlay::new(
189                cfg.active_region.clone(),
190                cfg.size_factor,
191                // The same floor the target's own hit test applies, from the
192                // same token, so the painted zone and the dropping zone are one
193                // rectangle.
194                ctx.theme().input.target_size,
195                border_width,
196                hint_cards,
197            ));
198            zstack = zstack.child(overlay);
199        }
200
201        ctx.add(zstack)
202    }
203}