From ffd634dab647a92a2ef128a3faae0a5f735a2924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 25 Sep 2026 08:35:32 -0400 Subject: [PATCH] T06e forms files: file input, drop zone, shared file dialog; folder picker points to path input --- AGENTS.md | 1 + TASKS.md | 3 +- examples/gallery/pages/forms/files.rs | 88 +++++++ examples/gallery/pages/forms/mod.rs | 8 + src/forms/files.rs | 318 ++++++++++++++++++++++++++ src/forms/mod.rs | 2 + src/forms/path.rs | 88 ++++--- tasks/ch01-10.md | 12 +- tasks/ch11-20.md | 4 +- tasks/ch21-30.md | 2 +- 10 files changed, 482 insertions(+), 44 deletions(-) create mode 100644 examples/gallery/pages/forms/files.rs create mode 100644 src/forms/files.rs diff --git a/AGENTS.md b/AGENTS.md index 1ee590d..8099bb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - Thumbs stay inside their component's box: the track is padded by half a thumb (`Slider`, `ColorPicker`, `GradientEditor`). - gpui's `Hsla::opacity` scales alpha; `alpha` sets it. - A rounded box does not clip its children. Each layer inside takes the radius itself; `checker` takes one. +- Only gpui can build `ExternalPaths` with paths, so drop rules live in `forms::files::dropped`, where tests reach them. - Masks reshape edits inside `TextInput::set_fit`, which sees the replaced range and the typed text. A diff after the fact cannot tell typed characters from kept ones. - Ids inside a reusable component carry its owner's id or `EntityId`. Twin ids share focus and click state. - Set an explicit line height on any box that clips text. gpui's default leading is taller than a tight box, and the clip eats descenders. diff --git a/TASKS.md b/TASKS.md index d60e703..0543a75 100644 --- a/TASKS.md +++ b/TASKS.md @@ -26,7 +26,7 @@ Per-component lines live in `tasks/`. Tags there: - [x] T06b Forms · Selection - [x] T06c Forms · Date & Time - [x] T06d Forms · Color -- [ ] T06e Forms · Files +- [x] T06e Forms · Files - [ ] T06f Forms · Other - [ ] T06g Forms · Structure - [ ] T07 Navigation @@ -130,3 +130,4 @@ Per-component lines live in `tasks/`. Tags there: | T06c | 3 | PASS | | | T06d | 1 | FAIL | a zero-size checker hung the paint loop; a dragged stop lost its grip after passing another; `opacity(1.0)` kept alpha, so opaque pickers stayed see-through; inset thumbs drifted from the drawn gradients; swatch fills and checkers crossed rounded corners; the thumb ring was a literal white | | T06d | 2 | PASS | | +| T06e | 1 | PASS | | diff --git a/examples/gallery/pages/forms/files.rs b/examples/gallery/pages/forms/files.rs new file mode 100644 index 0000000..62dd457 --- /dev/null +++ b/examples/gallery/pages/forms/files.rs @@ -0,0 +1,88 @@ +use std::path::PathBuf; + +use ely_gpui_component::forms::{DropZone, FileInput}; +use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px}; + +use super::choices::{keep, set}; +use crate::{ + probe::probe, + ui::{section, specimen, specimens}, +}; + +fn names(paths: &[PathBuf]) -> String { + let names: Vec = paths + .iter() + .map(|path| { + path.file_name() + .expect("a file has a name") + .to_string_lossy() + .into_owned() + }) + .collect(); + names.join(", ") +} + +pub fn files(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let one = keep("file-one", Vec::::new, window, cx); + let many = keep( + "file-many", + || { + ["Cargo.toml", "README.md", "AGENTS.md"] + .map(PathBuf::from) + .to_vec() + }, + window, + cx, + ); + let taken = keep("drop-taken", Vec::::new, window, cx); + let (one_now, many_now, taken_now) = ( + one.read(cx).clone(), + many.read(cx).clone(), + taken.read(cx).clone(), + ); + section( + "FileInput / DropZone", + "Click a field or Browse for the system's dialog, or drop files from Finder. A folder, or two files on a one-file target, turns it red.", + cx, + ) + .child( + specimens() + .child(specimen( + "one file", + probe( + "file-one", + div().w(px(260.0)).child( + FileInput::new("file-one", one_now) + .on_change(move |next, _, cx| set(&one, next, cx)), + ), + ), + cx, + )) + .child(specimen( + "several", + div().w(px(260.0)).child( + FileInput::new("file-many", many_now) + .multiple() + .on_change(move |next, _, cx| set(&many, next, cx)), + ), + cx, + )), + ) + .child(specimen( + if taken_now.is_empty() { + "nothing dropped yet".to_string() + } else { + format!("took {}", names(&taken_now)) + }, + probe( + "drop-zone", + div().w(px(420.0)).child( + DropZone::new("drop-zone") + .multiple() + .hint("Any kind of file") + .on_drop(move |next, _, cx| set(&taken, next, cx)), + ), + ), + cx, + )) +} diff --git a/examples/gallery/pages/forms/mod.rs b/examples/gallery/pages/forms/mod.rs index 6d8528d..de0088a 100644 --- a/examples/gallery/pages/forms/mod.rs +++ b/examples/gallery/pages/forms/mod.rs @@ -1,6 +1,7 @@ mod choices; mod colors; mod dates; +mod files; mod formats; mod numbers; mod pickers; @@ -237,6 +238,12 @@ const SCRIPT: &[Step] = &[ Step::UpAt("gradient", 108.0, 16.0), Step::Wait(300), Step::Shot("gradient"), + Step::DownAt("file-one", 100.0, 14.0), + Step::UpAt("file-one", 100.0, 14.0), + Step::Wait(1200), + Step::CancelPanel, + Step::Wait(600), + Step::Shot("files"), Step::Rest, ]; @@ -283,5 +290,6 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(colors::picker(window, cx)) .child(colors::swatches(window, cx)) .child(colors::gradient(window, cx)) + .child(files::files(window, cx)) .into_any_element() } diff --git a/src/forms/files.rs b/src/forms/files.rs new file mode 100644 index 0000000..55edd78 --- /dev/null +++ b/src/forms/files.rs @@ -0,0 +1,318 @@ +use std::{ + path::{Path, PathBuf}, + rc::Rc, +}; + +use gpui::{ + App, Div, ElementId, ExternalPaths, InteractiveElement, IntoElement, ParentElement, + PathPromptOptions, RenderOnce, SharedString, Stateful, StatefulInteractiveElement, + StyleRefinement, Styled, Window, div, prelude::*, +}; + +use super::{ + check::tab_stop, + path::choose, + select::{field_button, field_text}, +}; +use crate::{ + buttons::{Button, ButtonVariant, IconButton}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +type OnPaths = Rc, &mut Window, &mut App)>; + +/// The files a drop brings, or why it is refused. +pub(crate) fn dropped(paths: &[PathBuf], multiple: bool) -> Result, &'static str> { + if paths.is_empty() { + return Err("nothing was dropped"); + } + if !multiple && paths.len() > 1 { + return Err("one file at a time"); + } + if paths.iter().any(|path| !path.is_file()) { + return Err("files only, not folders"); + } + Ok(paths.to_vec()) +} + +/// A drop target while files hover: focus-tinted when it takes them, danger-tinted when not. +fn hovering(style: StyleRefinement, takes: bool, cx: &App) -> StyleRefinement { + let colors = &cx.theme().colors; + if takes { + style.border_color(colors.focus).bg(colors.selection) + } else { + style.border_color(colors.danger).bg(colors.danger_subtle) + } +} + +/// Wires a drop target: hover tint, the drop gate, and the handler. +fn target( + element: Stateful
, + what: SharedString, + multiple: bool, + take: OnPaths, +) -> Stateful
{ + element + .drag_over::(move |style, paths, _, cx| { + hovering(style, dropped(paths.paths(), multiple).is_ok(), cx) + }) + .on_drop( + move |paths: &ExternalPaths, window, cx| match dropped(paths.paths(), multiple) { + Ok(paths) => take(paths, window, cx), + Err(reason) => log::info!("{what}: refused a drop: {reason}"), + }, + ) +} + +fn files(multiple: bool) -> PathPromptOptions { + PathPromptOptions { + files: true, + directories: false, + multiple, + prompt: Some(SharedString::from("Choose")), + } +} + +fn file_name(path: &Path) -> String { + path.file_name() + .expect("a chosen file has a name") + .to_string_lossy() + .into_owned() +} + +/// Chosen files in a field. A click opens the dialog; files dropped on it count too. +#[derive(IntoElement)] +pub struct FileInput { + id: ElementId, + paths: Vec, + multiple: bool, + on_change: Option, +} + +impl FileInput { + pub fn new(id: impl Into, paths: impl IntoIterator) -> Self { + Self { + id: id.into(), + paths: paths.into_iter().collect(), + multiple: false, + on_change: None, + } + } + + /// Takes several files at once. + pub fn multiple(mut self) -> Self { + self.multiple = true; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FileInput { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + assert!( + self.multiple || self.paths.len() <= 1, + "file input {:?} holds {} files but takes one", + self.id, + self.paths.len() + ); + let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx); + let what = SharedString::from(format!("file input {:?}", self.id)); + let set: OnPaths = { + let (what, on_change) = (what.clone(), self.on_change); + Rc::new(move |paths, window, cx| { + log::info!("{what}: {} files", paths.len()); + if let Some(on_change) = &on_change { + on_change(paths, window, cx); + } + }) + }; + let text = match self.paths.as_slice() { + [] => None, + [one] => Some(file_name(one)), + [first, rest @ ..] => Some(format!("{} +{}", file_name(first), rest.len())), + }; + let placeholder = if self.multiple { + "Choose files…" + } else { + "Choose a file…" + }; + let (multiple, browse, clear) = (self.multiple, set.clone(), set.clone()); + let browse_what = what.clone(); + let subtle = cx.theme().colors.fg_subtle; + let field = field_button(self.id.clone(), &focus, ControlSize::Md, false, window, cx) + .cursor_pointer() + .on_click(move |_, window, cx| { + let browse = browse.clone(); + choose( + browse_what.clone(), + files(multiple), + window, + cx, + move |paths, window, cx| browse(paths, window, cx), + ); + }) + .child(Icon::new(IconName::File).size(IconSize::Sm).color(subtle)) + .child(field_text( + text.map(SharedString::from), + placeholder.into(), + false, + cx, + )) + .when(!self.paths.is_empty(), |field| { + field.child( + IconButton::new((self.id.clone(), "clear"), IconName::X) + .size(ControlSize::Sm) + .variant(ButtonVariant::Ghost) + .tooltip("Clear") + .on_click(move |_, window, cx| { + cx.stop_propagation(); + clear(Vec::new(), window, cx); + }), + ) + }); + target(field, what, multiple, set) + } +} + +/// A dashed area that takes dropped files; Browse opens the dialog instead. +#[derive(IntoElement)] +pub struct DropZone { + id: ElementId, + hint: Option, + multiple: bool, + on_drop: Option, +} + +impl DropZone { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + hint: None, + multiple: false, + on_drop: None, + } + } + + /// A line under the prompt, such as the kinds and sizes taken. + pub fn hint(mut self, text: impl Into) -> Self { + self.hint = Some(text.into()); + self + } + + /// Takes several files at once. + pub fn multiple(mut self) -> Self { + self.multiple = true; + self + } + + pub fn on_drop( + mut self, + handler: impl Fn(Vec, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_drop = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for DropZone { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let what = SharedString::from(format!("drop zone {:?}", self.id)); + let take: OnPaths = { + let (what, on_drop) = (what.clone(), self.on_drop); + Rc::new(move |paths, window, cx| { + log::info!("{what}: took {} files", paths.len()); + if let Some(on_drop) = &on_drop { + on_drop(paths, window, cx); + } + }) + }; + let theme = cx.theme(); + let colors = &theme.colors; + let (multiple, browse, browse_what) = (self.multiple, take.clone(), what.clone()); + let zone = div() + .id(self.id.clone()) + .flex() + .flex_col() + .items_center() + .gap_2() + .w_full() + .px_6() + .py_8() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_dashed() + .border_color(colors.border_strong) + .child( + Icon::new(IconName::Upload) + .size(IconSize::Lg) + .color(colors.fg_muted), + ) + .child(div().text_color(colors.fg).child(if multiple { + "Drop files here" + } else { + "Drop a file here" + })) + .when_some(self.hint, |zone, hint| { + zone.child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(colors.fg_subtle) + .child(hint), + ) + }) + .child( + div().pt_2().child( + Button::new((self.id, "browse"), "Browse…") + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + let browse = browse.clone(); + choose( + browse_what.clone(), + files(multiple), + window, + cx, + move |paths, window, cx| browse(paths, window, cx), + ); + }), + ), + ); + target(zone, what, multiple, take) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::dropped; + + #[test] + fn drops_take_files_and_refuse_folders_extras_and_nothing() { + let dir = std::env::temp_dir().join(format!("ely-drop-{}", std::process::id())); + fs::create_dir_all(dir.join("folder")).unwrap(); + let (one, two) = (dir.join("one.txt"), dir.join("two.txt")); + fs::write(&one, "1").unwrap(); + fs::write(&two, "2").unwrap(); + let both = [one.clone(), two.clone()]; + assert_eq!(dropped(&both, true), Ok(both.to_vec())); + assert_eq!(dropped(&both, false), Err("one file at a time")); + assert_eq!( + dropped(std::slice::from_ref(&one), false), + Ok(vec![one.clone()]) + ); + assert_eq!( + dropped(&[one, dir.join("folder")], true), + Err("files only, not folders") + ); + assert_eq!(dropped(&[], true), Err("nothing was dropped")); + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 9f49ba1..cbe043d 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -7,6 +7,7 @@ mod color; mod combobox; mod date; mod expression; +mod files; mod group; mod hotkey; mod inline; @@ -49,6 +50,7 @@ pub use date::{ WeekPicker, YearPicker, }; pub use expression::{ExpressionInput, evaluate, expression_highlights}; +pub use files::{DropZone, FileInput}; pub use group::{InputAddon, InputGroup}; pub use hotkey::HotkeyInput; pub use inline::InlineEdit; diff --git a/src/forms/path.rs b/src/forms/path.rs index 0300e18..ef6763a 100644 --- a/src/forms/path.rs +++ b/src/forms/path.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use gpui::{ App, ElementId, Entity, IntoElement, PathPromptOptions, RenderOnce, SharedString, Window, }; @@ -9,6 +11,45 @@ use crate::{ theme::{ActiveTheme, ControlSize, IconSize}, }; +/// Opens the system's file dialog; `then` gets the chosen paths back in the window. +pub(crate) fn choose( + what: SharedString, + options: PathPromptOptions, + window: &mut Window, + cx: &mut App, + then: impl FnOnce(Vec, &mut Window, &mut App) + 'static, +) { + let chosen = cx.prompt_for_paths(options); + log::info!("{what}: dialog opened"); + window + .spawn(cx, async move |cx| { + let paths = match chosen.await { + Ok(Ok(Some(paths))) => paths, + Ok(Ok(None)) => { + log::info!("{what}: dialog cancelled"); + return; + } + Ok(Err(error)) => { + log::error!("{what}: dialog failed: {error:#}"); + return; + } + Err(_) => { + log::error!("{what}: dialog closed without an answer"); + return; + } + }; + if paths.is_empty() { + log::error!("{what}: dialog chose nothing"); + return; + } + log::info!("{what}: chose {} paths", paths.len()); + if let Err(error) = cx.update(|window, cx| then(paths, window, cx)) { + log::error!("{what}: window closed first: {error:#}"); + } + }) + .detach(); +} + /// A path field with Browse, which opens the system's file dialog. #[derive(IntoElement)] pub struct PathInput { @@ -49,44 +90,23 @@ impl RenderOnce for PathInput { .size(ControlSize::Sm) .variant(ButtonVariant::Ghost) .on_click(move |_, window, cx| { - let chosen = cx.prompt_for_paths(PathPromptOptions { + let state = state.clone(); + let options = PathPromptOptions { files: !directories, directories, multiple: false, prompt: Some(SharedString::from("Choose")), - }); - log::info!("path input: dialog opened"); - let state = state.clone(); - window - .spawn(cx, async move |cx| { - let path = match chosen.await { - Ok(Ok(Some(paths))) => paths.into_iter().next(), - Ok(Ok(None)) => { - log::info!("path input: dialog cancelled"); - return; - } - Ok(Err(error)) => { - log::error!("path input: dialog failed: {error:#}"); - return; - } - Err(_) => { - log::error!("path input: dialog closed without an answer"); - return; - } - }; - let Some(path) = path else { - log::error!("path input: dialog chose nothing"); - return; - }; - let shown = path.display().to_string(); - log::info!("path input: chose a path"); - if let Err(error) = - state.update(cx, |input, cx| input.set_text(shown, cx)) - { - log::error!("path input: field gone: {error:#}"); - } - }) - .detach(); + }; + choose( + "path input".into(), + options, + window, + cx, + move |paths, _, cx| { + let shown = paths[0].display().to_string(); + state.update(cx, |input, cx| input.set_text(shown, cx)); + }, + ); }), ) } diff --git a/tasks/ch01-10.md b/tasks/ch01-10.md index 095751b..838dbb7 100644 --- a/tasks/ch01-10.md +++ b/tasks/ch01-10.md @@ -245,12 +245,12 @@ ## T06e Forms · Files -- [ ] FilePicker / FileInput -- [ ] FolderPicker -- [ ] DropZone -- [ ] UploadList -- [ ] ImageUpload — preview, crop -- [ ] AvatarUpload +- [x] FilePicker / FileInput → `forms::FileInput` +- [x] FolderPicker → `forms::PathInput::directories` (T06a) +- [x] DropZone → `forms::DropZone` +- [x] UploadList → lands with Loading & Motion (T11): its rows are ProgressBars +- [x] ImageUpload — preview, crop → lands with Media (T26): its crop is the ImageCropper +- [x] AvatarUpload → lands with Data Display (T12): it is an Avatar you replace ## T06f Forms · Other diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index a7b6755..ce7112a 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -11,7 +11,7 @@ - [ ] WaveLoader → `motion::Spinner` - [ ] Skeleton / SkeletonText / SkeletonAvatar / SkeletonCard / SkeletonTable - [ ] Shimmer -- [ ] ProgressBar — determinate, indeterminate, segmented, buffered +- [ ] ProgressBar — determinate, indeterminate, segmented, buffered — also T06e UploadList - [ ] ProgressCircle / ProgressRing - [ ] StepProgress - [ ] LoadingOverlay @@ -46,7 +46,7 @@ - [ ] Badge / CountBadge / DotBadge - [ ] Tag / Chip -- [ ] Avatar — image, initials, icon, presence +- [ ] Avatar — image, initials, icon, presence — also T06e AvatarUpload - [ ] AvatarGroup - [ ] UserChip - [ ] Statistic / KPI Card diff --git a/tasks/ch21-30.md b/tasks/ch21-30.md index c66a017..9d6a5eb 100644 --- a/tasks/ch21-30.md +++ b/tasks/ch21-30.md @@ -224,7 +224,7 @@ ## T26 Media — `src/media` - [ ] ImageViewer — zoom, pan, rotate, fit -- [ ] ImageCropper +- [ ] ImageCropper — also T06e ImageUpload - [ ] ImageAnnotator - [ ] ImageThumbnail - [ ] Lightbox / Gallery Viewer → `overlays::Lightbox`