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}