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}