use crate::core::{Color, Point, Rect};
use crate::event::dnd::{DragPayload, DropEffect, DropTarget};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::widget::capability::coercion::expect_string;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DropZoneState {
#[default]
Idle,
Hovering,
Accepted,
Rejected,
Dropped,
}
impl DropZoneState {
pub const fn as_str(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Hovering => "hovering",
Self::Accepted => "accepted",
Self::Rejected => "rejected",
Self::Dropped => "dropped",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match crate::widget::capability::coercion::normalize_key(token).as_str() {
"idle" => Some(Self::Idle),
"hovering" => Some(Self::Hovering),
"accepted" => Some(Self::Accepted),
"rejected" => Some(Self::Rejected),
"dropped" => Some(Self::Dropped),
_ => None,
}
}
pub const fn is_active(self) -> bool {
matches!(self, Self::Accepted | Self::Dropped)
}
}
pub struct DropZone {
base: BaseWidget,
accepted_type: String,
state: DropZoneState,
pub payload_dropped: Signal1<DragPayload>,
}
impl DropZone {
pub fn new(geometry: Rect, accepted_type: impl Into<String>) -> Self {
Self {
base: BaseWidget::new(WidgetKind::DropZone, geometry, "DropZone"),
accepted_type: accepted_type.into(),
state: DropZoneState::Idle,
payload_dropped: Signal1::new(),
}
}
pub fn accepted_type(&self) -> &str {
&self.accepted_type
}
pub fn set_accepted_type(&mut self, accepted_type: impl Into<String>) {
self.accepted_type = accepted_type.into();
self.set_state(DropZoneState::Idle);
}
pub fn state(&self) -> DropZoneState {
self.state
}
pub fn set_state(&mut self, state: DropZoneState) {
if self.state != state {
self.state = state;
self.base.request_redraw();
}
}
pub fn is_hovered(&self) -> bool {
self.state == DropZoneState::Accepted
}
pub fn report_hover(&mut self, payload: &DragPayload) {
self.set_state(if self.accepts(payload) {
DropZoneState::Accepted
} else {
DropZoneState::Rejected
});
}
pub fn report_hover_changed(&mut self, payload: &DragPayload) {
self.report_hover(payload);
}
pub fn clear_hover(&mut self) {
self.set_state(DropZoneState::Idle);
}
pub fn accepts(&self, payload: &DragPayload) -> bool {
payload.is_type(&self.accepted_type)
}
}
impl DropTarget for DropZone {
fn can_accept(&self, payload: &DragPayload) -> bool {
self.accepts(payload)
}
fn on_drop(&mut self, payload: &DragPayload, _pos: Point) -> DropEffect {
self.payload_dropped.emit(payload.clone());
self.set_state(DropZoneState::Dropped);
DropEffect::Copy
}
fn preview_rect(&self, _payload: &DragPayload, _pos: Point) -> Option<Rect> {
Some(self.geometry())
}
}
impl Widget for DropZone {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(200, 120)
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for DropZone {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"accepted_type" => Ok(CapabilityValue::String(self.accepted_type().to_string())),
"state" => Ok(CapabilityValue::String(self.state().as_str().to_string())),
"hovered" => Ok(CapabilityValue::Bool(self.is_hovered())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"accepted_type" => {
self.set_accepted_type(expect_string(value)?);
Ok(())
}
"state" => {
let token = expect_string(value)?;
let state =
DropZoneState::from_token(&token).ok_or(CapabilityAccessError::TypeMismatch)?;
self.set_state(state);
Ok(())
}
"hovered" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["accepted_type", "state", "hovered", BASE_PROPERTY_NAMES]
}
}
impl EventHandler for DropZone {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseLeave { .. } => self.clear_hover(),
_ => { }
}
}
}
impl Draw for DropZone {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("drop_zone");
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::rgb(36, 107, 201));
let window_fill = {
let manager = crate::style::theme_manager();
manager.current_theme().map(|active| active.colors.background).unwrap_or(Color::WHITE)
};
let surface = match style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
{
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.10),
};
let outline = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != surface)
.unwrap_or_else(|| surface.blend(&ink, 0.45));
let fill = match self.state {
DropZoneState::Idle => surface,
DropZoneState::Hovering => surface.blend(&ink, 0.06),
DropZoneState::Accepted => surface.blend(&ink, 0.12),
DropZoneState::Dropped => surface.blend(&ink, 0.20),
DropZoneState::Rejected => surface.blend(&Color::BLACK, 0.10),
};
context.fill_rect(rect, fill);
let (border, stroke) = match self.state {
DropZoneState::Idle | DropZoneState::Hovering => (outline, 1),
DropZoneState::Accepted => (ink, 2),
DropZoneState::Dropped => (ink, 2),
DropZoneState::Rejected => (outline.blend(&ink, 0.25), 1),
};
let dash = 8u32;
let mut x = rect.x;
while x + dash as i32 <= rect.x + rect.width as i32 {
context.draw_line_stroke(
Point::new(x, rect.y),
Point::new(x + dash as i32, rect.y),
border,
stroke,
);
context.draw_line_stroke(
Point::new(x, rect.y + rect.height as i32 - 1),
Point::new(x + dash as i32, rect.y + rect.height as i32 - 1),
border,
stroke,
);
x += (dash * 2) as i32;
}
let mut y = rect.y;
while y + dash as i32 <= rect.y + rect.height as i32 {
context.draw_line_stroke(
Point::new(rect.x, y),
Point::new(rect.x, y + dash as i32),
border,
stroke,
);
context.draw_line_stroke(
Point::new(rect.x + rect.width as i32 - 1, y),
Point::new(rect.x + rect.width as i32 - 1, y + dash as i32),
border,
stroke,
);
y += (dash * 2) as i32;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn zone() -> DropZone {
DropZone::new(Rect::new(0, 0, 200, 120), "text/plain")
}
#[test]
fn new_zone_records_its_accepted_type() {
let z = zone();
assert_eq!(z.accepted_type(), "text/plain");
assert!(!z.is_hovered());
}
#[test]
fn accepts_only_matching_type_id() {
let z = zone();
assert!(z.accepts(&DragPayload::new("text/plain", "doc1")));
assert!(!z.accepts(&DragPayload::new("image/png", "img1")));
}
#[test]
fn on_drop_emits_payload_and_confirms_the_drop() {
let mut z = zone();
let payload = DragPayload::new("text/plain", "doc1").with_label("doc1");
let got = std::sync::Arc::new(std::sync::Mutex::new(None::<DragPayload>));
let sink = got.clone();
z.payload_dropped.connect(move |p| {
*sink.lock().unwrap() = Some(p.as_ref().clone());
});
z.report_hover(&payload);
assert_eq!(z.state(), DropZoneState::Accepted);
let effect = z.on_drop(&payload, Point::new(10, 10));
assert_eq!(effect, DropEffect::Copy);
assert_eq!(z.state(), DropZoneState::Dropped);
assert!(z.state().is_active(), "a committed drop is still an active state");
let recorded = got.lock().unwrap().clone();
assert_eq!(recorded, Some(payload));
}
#[test]
fn set_accepted_type_clears_the_state() {
let mut z = zone();
z.set_state(DropZoneState::Accepted);
z.set_accepted_type("application/json");
assert_eq!(
z.state(),
DropZoneState::Idle,
"a state about the old accepted type must not survive the change"
);
assert_eq!(z.accepted_type(), "application/json");
}
#[test]
fn preview_rect_is_the_zone_geometry() {
let z = zone();
let payload = DragPayload::new("text/plain", "doc");
assert_eq!(z.preview_rect(&payload, Point::new(0, 0)), Some(Rect::new(0, 0, 200, 120)));
}
#[test]
fn a_refused_payload_and_an_accepted_one_are_different_states() {
let mut z = zone();
let accepted = DragPayload::new("text/plain", "doc1");
let refused = DragPayload::new("image/png", "img1");
assert_eq!(z.state(), DropZoneState::Idle, "a new zone is at rest");
z.report_hover(&refused);
assert_eq!(z.state(), DropZoneState::Rejected);
assert!(!z.is_hovered(), "a refused payload is not an accepted hover");
assert!(!z.state().is_active(), "and no drop would be taken");
z.report_hover_changed(&accepted);
assert_eq!(z.state(), DropZoneState::Accepted);
assert!(z.is_hovered());
assert!(z.state().is_active(), "a drop would now be taken");
z.report_hover_changed(&refused);
assert_eq!(z.state(), DropZoneState::Rejected);
z.clear_hover();
assert_eq!(z.state(), DropZoneState::Idle);
}
#[test]
fn every_feedback_state_paints_differently() {
let _theme_guard = crate::style::theme_test_guard();
use crate::widget::svg::render_to_svg;
let mut rendered = Vec::new();
for state in [
DropZoneState::Idle,
DropZoneState::Hovering,
DropZoneState::Accepted,
DropZoneState::Rejected,
DropZoneState::Dropped,
] {
let mut z = zone();
z.set_state(state);
rendered.push((state, render_to_svg(&mut z)));
}
for i in 0..rendered.len() {
for j in (i + 1)..rendered.len() {
let (a_state, a) = &rendered[i];
let (b_state, b) = &rendered[j];
assert_ne!(
a, b,
"{a_state:?} and {b_state:?} rendered identically, so the state is invisible"
);
}
}
}
#[test]
fn the_state_round_trips_through_the_property_api() {
let mut z = zone();
assert_eq!(z.get("state").unwrap().as_str(), Some("idle"));
for token in ["hovering", "accepted", "rejected", "dropped", "idle"] {
z.set("state", CapabilityValue::String(token.to_string())).unwrap();
assert_eq!(z.get("state").unwrap().as_str(), Some(token), "{token} did not round-trip");
}
z.set("state", CapabilityValue::String("ACCEPTED".to_string())).unwrap();
assert_eq!(z.state(), DropZoneState::Accepted);
assert!(z.set("state", CapabilityValue::String("nonsense".to_string())).is_err());
assert_eq!(z.state(), DropZoneState::Accepted, "a failed write must not change the state");
}
#[test]
fn the_legacy_hovered_flag_is_derived_from_the_state() {
let mut z = zone();
assert_eq!(z.get("hovered").unwrap().as_bool(), Some(false));
z.set("state", CapabilityValue::String("accepted".to_string())).unwrap();
assert_eq!(z.get("hovered").unwrap().as_bool(), Some(true));
z.set("state", CapabilityValue::String("rejected".to_string())).unwrap();
assert_eq!(
z.get("hovered").unwrap().as_bool(),
Some(false),
"a refused payload never counted as a hover"
);
assert!(z.set("hovered", CapabilityValue::Bool(true)).is_err());
}
}