use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use teksilo_i18n::{lit, tr_widget};
use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::accesskit::{Live, Role};
use teksilo_core::build_context::BuildContext;
use teksilo_core::styles::{
DropZoneStyle, DropZoneStyleConfig, DropZoneVisualState, SharedDropZoneStyle,
};
use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
use teksilo_core::widget_id::WidgetId;
use teksilo_core::{DragPayload, DropFeedback};
use teksilo_platform::file_dialog::{
EventContextFileDialogExt, FileDialogRequest, FileDialogResult,
};
use teksilo_tokens::{HAlignment, TextRole};
use crate::button::Button;
use crate::primitives::{TextWidget, VStack};
use teksilo_i18n::LocalizedString;
type FilesCallback = Box<dyn FnMut(Vec<PathBuf>, &mut EventContext)>;
type TextCallback = Box<dyn FnMut(String, &mut EventContext)>;
type UrlsCallback = Box<dyn FnMut(Vec<String>, &mut EventContext)>;
pub struct DropZone {
label: LocalizedString,
subtitle: Option<LocalizedString>,
browse_label: LocalizedString,
starting_dir: Option<PathBuf>,
extensions: Vec<String>,
allow_multiple: bool,
show_browse_button: bool,
icon: Option<Box<dyn Widget>>,
on_files: Option<FilesCallback>,
on_text: Option<TextCallback>,
on_urls: Option<UrlsCallback>,
style_override: Option<SharedDropZoneStyle>,
root_child_id: Option<WidgetId>,
}
impl DropZone {
pub fn new(label: impl Into<LocalizedString>) -> Self {
Self {
label: label.into(),
subtitle: None,
browse_label: lit!("Browse…"),
starting_dir: None,
extensions: Vec::new(),
allow_multiple: true,
show_browse_button: true,
icon: None,
on_files: None,
on_text: None,
on_urls: None,
style_override: None,
root_child_id: None,
}
}
pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
self.subtitle = Some(text.into());
self
}
pub fn accept_extensions<I, S>(mut self, extensions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.extensions = extensions
.into_iter()
.map(|e| e.into().trim_start_matches('.').to_ascii_lowercase())
.collect();
self
}
pub fn allow_multiple(mut self, allow: bool) -> Self {
self.allow_multiple = allow;
self
}
pub fn show_browse_button(mut self, show: bool) -> Self {
self.show_browse_button = show;
self
}
#[must_use]
pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.starting_dir = Some(path.into());
self
}
pub fn browse_label(mut self, label: impl Into<LocalizedString>) -> Self {
self.browse_label = label.into();
self
}
pub fn icon(mut self, icon: impl Widget + 'static) -> Self {
self.icon = Some(Box::new(icon));
self
}
pub fn style(mut self, style: impl DropZoneStyle) -> Self {
self.style_override = Some(Rc::new(style));
self
}
pub fn on_files_dropped(
mut self,
f: impl FnMut(Vec<PathBuf>, &mut EventContext) + 'static,
) -> Self {
self.on_files = Some(Box::new(f));
self
}
pub fn on_text_dropped(mut self, f: impl FnMut(String, &mut EventContext) + 'static) -> Self {
self.on_text = Some(Box::new(f));
self
}
pub fn on_urls_dropped(
mut self,
f: impl FnMut(Vec<String>, &mut EventContext) + 'static,
) -> Self {
self.on_urls = Some(Box::new(f));
self
}
}
impl std::fmt::Debug for DropZone {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DropZone")
.field("label", &self.label)
.field("extensions", &self.extensions)
.field("allow_multiple", &self.allow_multiple)
.finish_non_exhaustive()
}
}
fn payload_accepted(
payload: &DragPayload,
extensions: &[String],
allow_multiple: bool,
has_files_cb: bool,
has_text_cb: bool,
has_urls_cb: bool,
) -> bool {
let files = payload.files();
if !files.is_empty() {
if !has_files_cb {
return false;
}
if !allow_multiple && files.len() > 1 {
return false;
}
if extensions.is_empty() {
return true;
}
return files.iter().all(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| extensions.iter().any(|x| x.eq_ignore_ascii_case(e)))
.unwrap_or(false)
});
}
if payload.text().is_some() {
return has_text_cb;
}
if !payload.uris().is_empty() {
return has_urls_cb;
}
if payload.is_external() {
let formats = payload.formats();
let offers = |needles: &[&str]| {
formats
.iter()
.any(|f| needles.iter().any(|n| f == n || f.starts_with(n)))
};
if has_files_cb && offers(&["text/uri-list"]) {
return true;
}
if has_text_cb && offers(&["text/plain", "UTF8_STRING", "STRING", "TEXT"]) {
return true;
}
if has_urls_cb && offers(&["text/x-moz-url", "text/uri-list", "_NETSCAPE_URL"]) {
return true;
}
}
false
}
fn hover_announcement(payload: &DragPayload) -> String {
let files = payload.files().len();
if files == 1 {
return tr_widget!(drop_zone_hover_file_one()).resolve_now();
}
if files > 1 {
return tr_widget!(drop_zone_hover_file_many(count = files as i64)).resolve_now();
}
if payload.text().is_some() {
return tr_widget!(drop_zone_hover_text()).resolve_now();
}
let links = payload.uris().len();
if links == 1 {
return tr_widget!(drop_zone_hover_link_one()).resolve_now();
}
if links > 1 {
return tr_widget!(drop_zone_hover_link_many(count = links as i64)).resolve_now();
}
tr_widget!(drop_zone_hover_generic()).resolve_now()
}
fn added_announcement(payload: &DragPayload) -> String {
let files = payload.files().len();
if files >= 1 {
return added_files_announcement(files);
}
if payload.text().is_some() {
return tr_widget!(drop_zone_added_text()).resolve_now();
}
let links = payload.uris().len();
if links == 1 {
return tr_widget!(drop_zone_added_link_one()).resolve_now();
}
if links > 1 {
return tr_widget!(drop_zone_added_link_many(count = links as i64)).resolve_now();
}
added_files_announcement(files)
}
fn added_files_announcement(count: usize) -> String {
if count == 1 {
tr_widget!(drop_zone_added_file_one()).resolve_now()
} else {
tr_widget!(drop_zone_added_file_many(count = count as i64)).resolve_now()
}
}
impl Widget for DropZone {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let state = ctx.signal(DropZoneVisualState::Idle);
let announce = ctx.signal(String::new());
let extensions = self.extensions.clone();
let allow_multiple = self.allow_multiple;
let has_files_cb = self.on_files.is_some();
let has_text_cb = self.on_text.is_some();
let has_urls_cb = self.on_urls.is_some();
let on_files = self.on_files.take().map(|f| Rc::new(RefCell::new(f)));
let on_text = self.on_text.take().map(|f| Rc::new(RefCell::new(f)));
let on_urls = self.on_urls.take().map(|f| Rc::new(RefCell::new(f)));
let mut content = VStack::new().spacing(8.0).alignment(HAlignment::Center);
if let Some(icon) = self.icon.take() {
let icon_id = ctx.add_boxed(icon);
content = content.add_child(icon_id);
}
content = content.child(TextWidget::new(self.label.clone()));
if let Some(subtitle) = &self.subtitle {
content = content.child(TextWidget::new(subtitle.clone()).color(TextRole::Secondary));
}
content = content.child(
TextWidget::new(lit!(String::new()))
.text(announce.clone())
.color(TextRole::Secondary)
.access_live(Live::Polite),
);
if self.show_browse_button {
let browse_extensions = self.extensions.clone();
let allow_multiple_browse = self.allow_multiple;
let on_files_browse = on_files.clone();
let announce_browse = announce.clone();
let browse_starting_dir = self.starting_dir.clone();
let browse = Button::new(self.browse_label.clone()).on_activate_fn(
move |ctx: &mut EventContext| {
let mut request = FileDialogRequest::pick_file();
if let Some(dir) = &browse_starting_dir {
request = request.starting_dir(dir.clone());
}
if !browse_extensions.is_empty() {
let exts: Vec<&str> =
browse_extensions.iter().map(String::as_str).collect();
request = request.add_filter("Allowed", &exts);
}
let on_files_cb = on_files_browse.clone();
let announce_cb = announce_browse.clone();
let result_cb = move |result: FileDialogResult, ctx: &mut EventContext| {
let paths = match result {
FileDialogResult::File(Some(p)) => vec![p],
FileDialogResult::Files(v) => v,
_ => Vec::new(),
};
if paths.is_empty() {
return;
}
let count = paths.len();
if let Some(cb) = &on_files_cb {
(cb.borrow_mut())(paths, ctx);
}
announce_cb.set(added_files_announcement(count));
};
let _ = if allow_multiple_browse {
ctx.pick_files(request, result_cb)
} else {
ctx.pick_file(request, result_cb)
};
},
);
content = content.child(browse);
}
let content_id = ctx.add(content);
let style = self
.style_override
.clone()
.or_else(|| ctx.theme().style_slots.drop_zone.clone())
.unwrap_or_else(|| Rc::new(crate::styles::RecipeDropZoneStyle::default()));
let body = style.make_body(
&DropZoneStyleConfig {
state: state.clone(),
content: content_id,
},
ctx,
);
let hover_state = state.clone();
let hover_announce = announce.clone();
let hover_exts = extensions.clone();
let leave_state = state.clone();
let leave_announce = announce.clone();
let drop_exts = extensions;
let handlers = HandlerSet::new()
.on_drag_hover(move |payload, _pos, _ctx| {
let ok = payload_accepted(
payload,
&hover_exts,
allow_multiple,
has_files_cb,
has_text_cb,
has_urls_cb,
);
if ok {
hover_state.set(DropZoneVisualState::HoverAccept);
hover_announce.set(hover_announcement(payload));
} else {
hover_state.set(DropZoneVisualState::HoverReject);
hover_announce.set(tr_widget!(drop_zone_hover_reject()).resolve_now());
}
if ok {
DropFeedback::Accept
} else {
DropFeedback::NoFeedback
}
})
.on_drag_leave(move |_ctx| {
leave_state.set(DropZoneVisualState::Idle);
leave_announce.set(String::new());
})
.on_drop(move |payload, _pos, ctx| {
let ok = payload_accepted(
&payload,
&drop_exts,
allow_multiple,
has_files_cb,
has_text_cb,
has_urls_cb,
);
state.set(DropZoneVisualState::Idle);
if !ok {
announce.set(tr_widget!(drop_zone_rejected()).resolve_now());
return false;
}
if !payload.files().is_empty() {
if let Some(cb) = &on_files {
(cb.borrow_mut())(payload.files().to_vec(), ctx);
}
} else if let Some(text) = payload.text() {
if let Some(cb) = &on_text {
(cb.borrow_mut())(text.to_string(), ctx);
}
} else if !payload.uris().is_empty() {
if let Some(cb) = &on_urls {
(cb.borrow_mut())(payload.uris().to_vec(), ctx);
}
}
announce.set(added_announcement(&payload));
true
});
ctx.apply_self_handlers(handlers);
self.root_child_id = Some(body);
self.children()
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root_child_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
_ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(Role::Group);
builder.set_name(self.label.clone());
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
use teksilo_canvas::Point;
use teksilo_core::ExternalDropData;
use teksilo_core::widget_tree::WidgetTree;
fn tree() -> WidgetTree {
WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
}
#[test]
fn a_starting_directory_reaches_the_built_zone() {
let zone = DropZone::new(lit!("Drop files here")).starting_dir("/tmp/somewhere");
assert_eq!(
zone.starting_dir.as_deref(),
Some(std::path::Path::new("/tmp/somewhere"))
);
let mut tree = tree();
let id = tree.add(zone);
tree.layout(SizeProposal::exact(400.0, 300.0));
let b = tree.bounds(id);
assert!(
b.width > 0.0 && b.height > 0.0,
"a zone carrying a starting directory still builds"
);
}
#[test]
fn builds_with_nonzero_size() {
let mut tree = tree();
let id = tree.add(DropZone::new(lit!("Drop files here")));
tree.layout(SizeProposal::exact(400.0, 300.0));
let b = tree.bounds(id);
assert!(b.width > 0.0 && b.height > 0.0);
}
#[test]
fn matching_file_drop_fires_callback() {
let mut tree = tree();
let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
let g = got.clone();
tree.add(
DropZone::new(lit!("Images"))
.accept_extensions(["png", "jpg"])
.on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let mut noop = teksilo_core::NoopWindowOps;
let data = ExternalDropData {
files: vec![PathBuf::from("/tmp/photo.png")],
..Default::default()
};
let p = Point::new(200.0, 150.0);
tree.begin_external_drag(p, data.clone(), &mut noop);
tree.end_external_drag(p, data, &mut noop);
assert_eq!(*got.borrow(), vec![PathBuf::from("/tmp/photo.png")]);
}
#[test]
fn wrong_extension_is_rejected() {
let mut tree = tree();
let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
let g = got.clone();
tree.add(
DropZone::new(lit!("Images"))
.accept_extensions(["png"])
.on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let mut noop = teksilo_core::NoopWindowOps;
let data = ExternalDropData {
files: vec![PathBuf::from("/tmp/notes.txt")],
..Default::default()
};
let p = Point::new(200.0, 150.0);
tree.begin_external_drag(p, data.clone(), &mut noop);
tree.end_external_drag(p, data, &mut noop);
assert!(got.borrow().is_empty(), "non-png drop must be rejected");
}
#[test]
fn multi_file_rejected_when_single_only() {
let mut tree = tree();
let got: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
let g = got.clone();
tree.add(
DropZone::new(lit!("One file"))
.allow_multiple(false)
.on_files_dropped(move |paths, _ctx| *g.borrow_mut() = paths),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let mut noop = teksilo_core::NoopWindowOps;
let data = ExternalDropData {
files: vec![PathBuf::from("/a"), PathBuf::from("/b")],
..Default::default()
};
let p = Point::new(200.0, 150.0);
tree.begin_external_drag(p, data.clone(), &mut noop);
tree.end_external_drag(p, data, &mut noop);
assert!(got.borrow().is_empty(), "multi-file drop must be rejected");
}
#[test]
fn text_drop_fires_when_handler_set() {
let mut tree = tree();
let got: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
let g = got.clone();
tree.add(
DropZone::new(lit!("Notes")).on_text_dropped(move |t, _ctx| *g.borrow_mut() = Some(t)),
);
tree.layout(SizeProposal::exact(400.0, 300.0));
let mut noop = teksilo_core::NoopWindowOps;
let data = ExternalDropData {
text: Some("hello".to_string()),
..Default::default()
};
let p = Point::new(200.0, 150.0);
tree.begin_external_drag(p, data.clone(), &mut noop);
tree.end_external_drag(p, data, &mut noop);
assert_eq!(got.borrow().as_deref(), Some("hello"));
}
#[test]
fn formats_only_hover_accepts_matching_kind() {
let file_drag = DragPayload::external(ExternalDropData {
formats: vec!["text/uri-list".into(), "text/plain".into()],
..Default::default()
});
assert!(payload_accepted(
&file_drag,
&["png".into()],
true,
true,
false,
false
));
let text_drag = DragPayload::external(ExternalDropData {
formats: vec!["text/plain".into()],
..Default::default()
});
assert!(!payload_accepted(&text_drag, &[], true, true, false, false));
assert!(payload_accepted(&text_drag, &[], true, false, true, false));
}
#[test]
fn formats_only_internal_drag_is_not_accepted() {
let internal = DragPayload::typed(7_u32);
assert!(!payload_accepted(&internal, &[], true, true, true, true));
}
}