Skip to main content

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}