teksilo_core/styles/drop_target_style.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tier-3 style protocol for `DropTarget`. See `docs/styling-system.md`.
5//!
6//! `DropTarget` is the *wrapping* counterpart to [`DropZone`]: it turns any
7//! existing widget subtree into a drop target without replacing its visual
8//! identity. The wrapped child fills the bounds and is always visible; the
9//! style adds a reactive border + tint overlay that tracks the drag state
10//! (idle / accepting / rejecting) and, when a hint slot is set, a centered
11//! popup card.
12//!
13//! Like [`DropZoneStyle`](crate::styles::DropZoneStyle), the chrome reacts to
14//! hover, so the config carries a `Signal<DropTargetDragState>` — `make_body`
15//! binds the overlay's surface/border colors to it so they update without a
16//! rebuild.
17//!
18//! [`DropZone`]: ../../teksilo_widgets/drop_zone
19
20use std::rc::Rc;
21
22use teksilo_canvas::{Point, Rect, Size};
23use teksilo_tokens::{BorderRole, SurfaceRole};
24
25use crate::build_context::BuildContext;
26use crate::signal::Signal;
27use crate::widget_id::WidgetId;
28
29/// Interaction state of a drop target, driving the overlay's surface and
30/// border colors and the hint card's visibility. Defined here (not in
31/// `teksilo-widgets`) so the core style trait and the default recipe can both
32/// name it — mirroring [`DropZoneVisualState`](crate::styles::DropZoneVisualState).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DropTargetDragState {
35 /// At rest — no drag over the target. Overlay is fully transparent so the
36 /// wrapped child shows through untouched.
37 Idle,
38 /// A drag is over the target carrying acceptable data.
39 HoverAccept,
40 /// A drag is over the target but its data is rejected by the accept filter.
41 HoverReject,
42}
43
44impl DropTargetDragState {
45 /// Background surface-tint role for this state.
46 pub fn surface_role(self) -> SurfaceRole {
47 match self {
48 Self::Idle => SurfaceRole::Transparent,
49 Self::HoverAccept => SurfaceRole::AccentSubtle,
50 Self::HoverReject => SurfaceRole::StatusError,
51 }
52 }
53
54 /// Border role for this state.
55 pub fn border_role(self) -> BorderRole {
56 match self {
57 Self::Idle => BorderRole::Transparent,
58 Self::HoverAccept => BorderRole::Accent,
59 Self::HoverReject => BorderRole::Error,
60 }
61 }
62}
63
64/// Visual prominence of the drop target's hover indicator.
65///
66/// The default recipe draws the highlight as a **border only** (a solid stroke
67/// over the child) so the wrapped content is never hidden — an opaque surface
68/// tint would cover it. A translucent wash, dashed border, or glow requires a
69/// custom [`DropTargetStyle`]; the [`DropTargetDragState::surface_role`] helper
70/// is provided for styles that want a fill.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
72pub enum DropTargetVariant {
73 /// 2 px solid role-colored highlight border. Default.
74 #[default]
75 Default,
76 /// 3 px solid border — visually heavier, for primary drop zones.
77 Prominent,
78 /// 1 px thin border. Minimal visual footprint.
79 Subtle,
80 /// No built-in feedback; the style returns only the user's child (and the
81 /// hint, if any). For fully custom visuals driven from a bound signal.
82 None,
83}
84
85/// One of the five drop regions a [`DropTarget`](../../teksilo_widgets/drop_target)
86/// can expose. Each is independently enable-able and carries an optional hint.
87///
88/// `Leading`/`Trailing` are writing-direction-relative *by intent*; the v1
89/// hit-test maps `Leading`→left and `Trailing`→right (LTR only — the framework
90/// exposes no writing direction on the layout/paint contexts yet, so RTL
91/// mirroring is a documented follow-up).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
93pub enum DropRegion {
94 /// The middle of the target — whatever the side zones don't claim.
95 Center,
96 /// The top edge strip.
97 Top,
98 /// The bottom edge strip.
99 Bottom,
100 /// The leading edge strip (left in LTR).
101 Leading,
102 /// The trailing edge strip (right in LTR).
103 Trailing,
104}
105
106impl DropRegion {
107 /// All five regions, in the hit-test priority order (side zones before
108 /// centre; leading→trailing→top→bottom among the sides).
109 pub const ALL: [DropRegion; 5] = [
110 DropRegion::Leading,
111 DropRegion::Trailing,
112 DropRegion::Top,
113 DropRegion::Bottom,
114 DropRegion::Center,
115 ];
116
117 /// True for the four side (edge) zones — the ones sized by `size_factor`.
118 /// `Center` is the leftover middle and is never a side zone.
119 pub fn is_side(self) -> bool {
120 !matches!(self, DropRegion::Center)
121 }
122}
123
124/// Which [`DropRegion`]s a [`DropTarget`](../../teksilo_widgets/drop_target)
125/// currently exposes. The default (`Center` only) reproduces the classic
126/// whole-bounds single-zone behaviour.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct DropRegionSet {
129 /// Whether the centre region is enabled.
130 pub center: bool,
131 /// Whether the top edge region is enabled.
132 pub top: bool,
133 /// Whether the bottom edge region is enabled.
134 pub bottom: bool,
135 /// Whether the leading edge region is enabled.
136 pub leading: bool,
137 /// Whether the trailing edge region is enabled.
138 pub trailing: bool,
139}
140
141impl Default for DropRegionSet {
142 /// Centre only — the whole-bounds single-zone default.
143 fn default() -> Self {
144 Self {
145 center: true,
146 top: false,
147 bottom: false,
148 leading: false,
149 trailing: false,
150 }
151 }
152}
153
154impl DropRegionSet {
155 /// An empty set — no region enabled. Enable regions with [`Self::with`].
156 pub fn none() -> Self {
157 Self {
158 center: false,
159 top: false,
160 bottom: false,
161 leading: false,
162 trailing: false,
163 }
164 }
165
166 /// Is `region` enabled in this set?
167 pub fn contains(self, region: DropRegion) -> bool {
168 match region {
169 DropRegion::Center => self.center,
170 DropRegion::Top => self.top,
171 DropRegion::Bottom => self.bottom,
172 DropRegion::Leading => self.leading,
173 DropRegion::Trailing => self.trailing,
174 }
175 }
176
177 /// A copy with `region` enabled.
178 pub fn with(mut self, region: DropRegion) -> Self {
179 match region {
180 DropRegion::Center => self.center = true,
181 DropRegion::Top => self.top = true,
182 DropRegion::Bottom => self.bottom = true,
183 DropRegion::Leading => self.leading = true,
184 DropRegion::Trailing => self.trailing = true,
185 }
186 self
187 }
188
189 /// The enabled regions, in [`DropRegion::ALL`] order.
190 pub fn iter(self) -> impl Iterator<Item = DropRegion> {
191 DropRegion::ALL
192 .into_iter()
193 .filter(move |r| self.contains(*r))
194 }
195}
196
197/// Clamp a caller-supplied side-zone size factor to the supported `0.1..=1.0`
198/// range. The factor is the fraction of the relevant axis each **side** zone
199/// occupies (e.g. `0.5` bisects; the docking default was `0.2`).
200pub fn clamp_size_factor(factor: f32) -> f32 {
201 factor.clamp(0.1, 1.0)
202}
203
204/// Classify a pointer in widget-local coordinates into an enabled [`DropRegion`],
205/// or `None` when it lands in a middle with no `Center` enabled.
206///
207/// Generalizes docking's `compute_drop_zone`: each side zone is a
208/// `size_factor`-thick strip along its edge (no fixed pixel cap — the factor is
209/// the caller's knob). Only **enabled** edges are tested, in
210/// leading→trailing→top→bottom priority (so with a large factor an overlapping
211/// leading strip wins over trailing); the remaining middle resolves to `Center`
212/// when enabled, else `None`.
213pub fn region_at(
214 local: Point,
215 size: Size,
216 set: DropRegionSet,
217 size_factor: f32,
218) -> Option<DropRegion> {
219 let f = clamp_size_factor(size_factor);
220 let ex = size.width * f;
221 let ey = size.height * f;
222 if set.leading && local.x < ex {
223 Some(DropRegion::Leading)
224 } else if set.trailing && local.x > size.width - ex {
225 Some(DropRegion::Trailing)
226 } else if set.top && local.y < ey {
227 Some(DropRegion::Top)
228 } else if set.bottom && local.y > size.height - ey {
229 Some(DropRegion::Bottom)
230 } else if set.center {
231 Some(DropRegion::Center)
232 } else {
233 None
234 }
235}
236
237/// The highlight / hint rectangle for `region` within `bounds`: `Center` is the
238/// whole rectangle; a side zone is the `size_factor`-thick strip along its edge
239/// (visual == hit region — the zone the user sees is the zone that drops).
240///
241/// Note: a `Top`/`Bottom` strip spans the **full width** (and `Leading`/`Trailing`
242/// the full height). When both a horizontal and a vertical edge are enabled, the
243/// corner cells of a `Top`/`Bottom` rect visually overlap the area that
244/// [`region_at`] actually classifies as `Leading`/`Trailing` (which win the
245/// priority tie). This matches the docking overlay's half-rect precedent and is
246/// harmless — only one region is ever highlighted at a time — but the painted rect
247/// is a superset of that region's true hit area at the corners.
248pub fn region_rect(region: DropRegion, bounds: Rect, size_factor: f32) -> Rect {
249 let f = clamp_size_factor(size_factor);
250 let ex = bounds.width * f;
251 let ey = bounds.height * f;
252 match region {
253 DropRegion::Center => bounds,
254 DropRegion::Leading => Rect::new(bounds.x, bounds.y, ex, bounds.height),
255 DropRegion::Trailing => {
256 Rect::new(bounds.x + bounds.width - ex, bounds.y, ex, bounds.height)
257 }
258 DropRegion::Top => Rect::new(bounds.x, bounds.y, bounds.width, ey),
259 DropRegion::Bottom => Rect::new(bounds.x, bounds.y + bounds.height - ey, bounds.width, ey),
260 }
261}
262
263/// Inputs handed to a [`DropTargetStyle`] to build the wrapping chrome.
264#[derive(Clone)]
265pub struct DropTargetStyleConfig {
266 /// The user's child widget — fills the full bounds and is always visible.
267 pub content_id: WidgetId,
268 /// Reactive overall interaction state (idle / accepting / rejecting) —
269 /// bind overlay border colors and the reject tint to it.
270 pub drag_state: Signal<DropTargetDragState>,
271 /// Which region the pointer is currently over while an accepted payload
272 /// hovers (`None` when idle, rejecting, or over a disabled middle). Drives
273 /// the per-zone highlight and which hint is shown.
274 pub active_region: Signal<Option<DropRegion>>,
275 /// Which regions this target exposes. `Center`-only is the classic
276 /// whole-bounds single-zone case.
277 pub regions: DropRegionSet,
278 /// Pre-built per-region hint content (user slots), each centered inside a
279 /// popup card within its region's rect while that region is the active
280 /// accepted-hover. Empty when no hints were set.
281 pub region_hints: Vec<(DropRegion, WidgetId)>,
282 /// Side-zone size factor (already clamped to `0.1..=1.0`): the fraction of
283 /// the axis each edge zone occupies.
284 pub size_factor: f32,
285 /// Visual prominence requested by the caller.
286 pub variant: DropTargetVariant,
287}
288
289/// Tier-3 style protocol for [`DropTarget`](../../teksilo_widgets/drop_target).
290/// Produces the body: the wrapped child plus the reactive overlay and the
291/// optional centered hint.
292pub trait DropTargetStyle: 'static {
293 fn make_body(&self, cfg: &DropTargetStyleConfig, ctx: &mut BuildContext) -> WidgetId;
294}
295
296/// Shared, theme-installable handle to a [`DropTargetStyle`].
297pub type SharedDropTargetStyle = Rc<dyn DropTargetStyle>;
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 const FULL: DropRegionSet = DropRegionSet {
304 center: true,
305 top: true,
306 bottom: true,
307 leading: true,
308 trailing: true,
309 };
310
311 #[test]
312 fn center_only_classifies_everything_as_center() {
313 let set = DropRegionSet::default();
314 let size = Size::new(400.0, 300.0);
315 for &(x, y) in &[(0.0, 0.0), (200.0, 150.0), (399.0, 299.0)] {
316 assert_eq!(
317 region_at(Point::new(x, y), size, set, 0.2),
318 Some(DropRegion::Center)
319 );
320 }
321 }
322
323 #[test]
324 fn full_five_zone_edges_and_center() {
325 let size = Size::new(400.0, 300.0);
326 // 20% strips: ex = 80, ey = 60.
327 assert_eq!(
328 region_at(Point::new(5.0, 150.0), size, FULL, 0.2),
329 Some(DropRegion::Leading)
330 );
331 assert_eq!(
332 region_at(Point::new(395.0, 150.0), size, FULL, 0.2),
333 Some(DropRegion::Trailing)
334 );
335 assert_eq!(
336 region_at(Point::new(200.0, 5.0), size, FULL, 0.2),
337 Some(DropRegion::Top)
338 );
339 assert_eq!(
340 region_at(Point::new(200.0, 295.0), size, FULL, 0.2),
341 Some(DropRegion::Bottom)
342 );
343 assert_eq!(
344 region_at(Point::new(200.0, 150.0), size, FULL, 0.2),
345 Some(DropRegion::Center)
346 );
347 }
348
349 #[test]
350 fn only_enabled_edges_are_tested() {
351 // Leading + trailing only, no center: the middle is `None` (rejected).
352 let set = DropRegionSet::none()
353 .with(DropRegion::Leading)
354 .with(DropRegion::Trailing);
355 let size = Size::new(400.0, 300.0);
356 assert_eq!(
357 region_at(Point::new(5.0, 150.0), size, set, 0.2),
358 Some(DropRegion::Leading)
359 );
360 assert_eq!(
361 region_at(Point::new(200.0, 150.0), size, set, 0.2),
362 None,
363 "middle with no Center enabled must reject"
364 );
365 // A point in the top strip is NOT top (top disabled) → falls to middle → None.
366 assert_eq!(region_at(Point::new(200.0, 5.0), size, set, 0.2), None);
367 }
368
369 #[test]
370 fn factor_half_bisects_left_right() {
371 let set = DropRegionSet::none()
372 .with(DropRegion::Leading)
373 .with(DropRegion::Trailing);
374 let size = Size::new(400.0, 300.0);
375 // ex = 200: x<200 → leading, x>200 → trailing.
376 assert_eq!(
377 region_at(Point::new(199.0, 150.0), size, set, 0.5),
378 Some(DropRegion::Leading)
379 );
380 assert_eq!(
381 region_at(Point::new(201.0, 150.0), size, set, 0.5),
382 Some(DropRegion::Trailing)
383 );
384 }
385
386 #[test]
387 fn leading_wins_overlap_with_large_factor() {
388 // factor 0.9 overlaps leading and trailing across most of the width;
389 // priority order (leading first) resolves the overlap.
390 let size = Size::new(400.0, 300.0);
391 assert_eq!(
392 region_at(Point::new(200.0, 150.0), size, FULL, 0.9),
393 Some(DropRegion::Leading)
394 );
395 }
396
397 #[test]
398 fn size_factor_is_clamped() {
399 assert_eq!(clamp_size_factor(0.05), 0.1);
400 assert_eq!(clamp_size_factor(2.0), 1.0);
401 assert_eq!(clamp_size_factor(0.3), 0.3);
402 }
403
404 #[test]
405 fn region_rect_strips() {
406 let b = Rect::new(10.0, 20.0, 400.0, 300.0);
407 assert_eq!(region_rect(DropRegion::Center, b, 0.25), b);
408 // ex = 100, ey = 75.
409 let lead = region_rect(DropRegion::Leading, b, 0.25);
410 assert_eq!(
411 (lead.x, lead.y, lead.width, lead.height),
412 (10.0, 20.0, 100.0, 300.0)
413 );
414 let trail = region_rect(DropRegion::Trailing, b, 0.25);
415 assert_eq!(
416 (trail.x, trail.y, trail.width, trail.height),
417 (310.0, 20.0, 100.0, 300.0)
418 );
419 let top = region_rect(DropRegion::Top, b, 0.25);
420 assert_eq!(
421 (top.x, top.y, top.width, top.height),
422 (10.0, 20.0, 400.0, 75.0)
423 );
424 let bottom = region_rect(DropRegion::Bottom, b, 0.25);
425 assert_eq!(
426 (bottom.x, bottom.y, bottom.width, bottom.height),
427 (10.0, 245.0, 400.0, 75.0)
428 );
429 }
430}