use std::rc::Rc;
use gpui::{
App, ExternalPaths, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
Styled, Window, div, prelude::FluentBuilder, px,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, Radius, Space, TypeScale};
use crate::foundation::{Disableable, Ident, StyledExt, text as foundation_text};
use crate::interaction::dnd::{self, DragItem, FILE_KIND};
use crate::layout::measure;
use crate::strings::{ActiveStrings, StringKey};
type DropHandler = Rc<dyn Fn(&DragItem, &mut Window, &mut App)>;
type FilesHandler = Rc<dyn Fn(&ExternalPaths, &mut Window, &mut App)>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropzoneState {
Idle,
Accepting,
Refusing,
}
impl DropzoneState {
pub fn name(self) -> &'static str {
match self {
Self::Idle => "idle",
Self::Accepting => "accepting",
Self::Refusing => "refusing",
}
}
}
#[derive(IntoElement)]
pub struct Dropzone {
ident: Ident,
label: SharedString,
hint: Option<SharedString>,
refusal: Option<SharedString>,
kinds: Vec<SharedString>,
pinned: Option<DropzoneState>,
disabled: bool,
icon: Option<Icon>,
on_drop: Option<DropHandler>,
on_files: Option<FilesHandler>,
}
impl std::fmt::Debug for Dropzone {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Dropzone")
.field("ident", &self.ident)
.field("kinds", &self.kinds)
.field("pinned", &self.pinned)
.field("disabled", &self.disabled)
.field(
"has_handler",
&(self.on_drop.is_some() || self.on_files.is_some()),
)
.finish()
}
}
impl Dropzone {
pub fn new(ident: impl Into<Ident>, label: impl Into<SharedString>) -> Self {
Self {
ident: ident.into(),
label: label.into(),
hint: None,
refusal: None,
kinds: vec![SharedString::new_static(FILE_KIND)],
pinned: None,
disabled: false,
icon: Some(Icon::Paperclip),
on_drop: None,
on_files: None,
}
}
pub fn hint(mut self, hint: impl Into<SharedString>) -> Self {
self.hint = Some(hint.into());
self
}
pub fn refusal(mut self, refusal: impl Into<SharedString>) -> Self {
self.refusal = Some(refusal.into());
self
}
pub fn accepts<S: Into<SharedString>>(mut self, kinds: impl IntoIterator<Item = S>) -> Self {
self.kinds = kinds.into_iter().map(Into::into).collect();
self
}
pub fn icon(mut self, icon: Icon) -> Self {
self.icon = Some(icon);
self
}
pub fn state(mut self, state: DropzoneState) -> Self {
self.pinned = Some(state);
self
}
pub fn on_drop(mut self, handler: impl Fn(&DragItem, &mut Window, &mut App) + 'static) -> Self {
self.on_drop = Some(Rc::new(handler));
self
}
pub fn on_files(
mut self,
handler: impl Fn(&ExternalPaths, &mut Window, &mut App) + 'static,
) -> Self {
self.on_files = Some(Rc::new(handler));
self
}
}
impl Disableable for Dropzone {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
impl RenderOnce for Dropzone {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
dnd::sync(cx);
let measured = measure::cell(&self.ident.semantic_id(), cx);
let over = measured.get().contains(&window.mouse_position());
let carried = dnd::active(window, cx).map(|drag| drag.item);
let handles = |item: &DragItem| self.kinds.contains(&item.kind);
let state = self.pinned.unwrap_or(match &carried {
Some(item) if over && !self.disabled => {
if handles(item) {
DropzoneState::Accepting
} else {
DropzoneState::Refusing
}
}
_ => DropzoneState::Idle,
});
let (border, text, message) = match state {
DropzoneState::Idle => (
theme.colors.hairline_strong,
theme.colors.text_muted,
self.label.clone(),
),
DropzoneState::Accepting => {
(theme.colors.accent, theme.colors.text, self.label.clone())
}
DropzoneState::Refusing => (
theme.colors.danger,
theme.colors.danger,
self.refusal
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::DropzoneRefusal)),
),
};
let mut zone = div()
.id(self.ident.element_id())
.column()
.items_center()
.justify_center()
.gap_token(&theme, Space::Xs)
.p_token(&theme, Space::Lg)
.border(px(theme.borders.thick))
.border_color(border)
.radius(&theme, Radius::Card)
.when(state == DropzoneState::Accepting, |element| {
element.bg(theme
.colors
.accent
.opacity(theme.effects.selected_ring_alpha))
})
.when(self.disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.children(self.icon.map(|glyph| {
icon(glyph)
.size(px(theme.control.md.icon_size))
.text_color(text)
}))
.child(foundation_text(&theme, TypeScale::Label, message.clone()).text_color(text))
.children(
self.hint
.clone()
.filter(|_| state == DropzoneState::Idle)
.map(|hint| {
foundation_text(&theme, TypeScale::Caption, hint)
.text_tone(&theme, gpui_kit_theme::TextTone::Faint)
}),
);
let live = !self.disabled;
if live {
zone = zone.on_drag_move::<ExternalPaths>(|event, _window, cx| {
let count = event.drag(cx).paths().len();
dnd::adopt_external(count, cx);
});
}
if let (true, Some(handler)) = (live, self.on_drop.clone()) {
let kinds = self.kinds.clone();
zone = zone
.can_drop(move |payload, _, _| {
payload
.downcast_ref::<DragItem>()
.is_some_and(|item| kinds.contains(&item.kind))
})
.on_drop::<DragItem>(move |item, window, cx| {
dnd::finish(cx);
handler(item, window, cx);
});
}
if let (true, Some(handler)) = (live, self.on_files.clone()) {
zone = zone.on_drop::<ExternalPaths>(move |paths, window, cx| {
dnd::finish(cx);
handler(paths, window, cx);
});
}
let zone = zone.semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::Region)
.text(message)
.value(state.name())
.disabled(self.disabled)
.selected(state == DropzoneState::Accepting)
.invalid(state == DropzoneState::Refusing),
);
div()
.column()
.on_children_prepainted(move |bounds, window, _| {
if let Some(first) = bounds.first() {
measure::record(&measured, *first, window);
}
})
.child(zone)
}
}