T06e forms files: file input, drop zone, shared file dialog; folder picker points to path input
This commit is contained in:
10 files changed
+482
-44
No files matched your search
@@ -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`).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
- [x] T06b Forms · Selection
|
- [x] T06b Forms · Selection
|
||||||
- [x] T06c Forms · Date & Time
|
- [x] T06c Forms · Date & Time
|
||||||
- [x] T06d Forms · Color
|
- [x] T06d Forms · Color
|
||||||
- [ ] T06e Forms · Files
|
- [x] T06e Forms · Files
|
||||||
- [ ] T06f Forms · Other
|
- [ ] T06f Forms · Other
|
||||||
- [ ] T06g Forms · Structure
|
- [ ] T06g Forms · Structure
|
||||||
- [ ] T07 Navigation
|
- [ ] T07 Navigation
|
||||||
@@ -130,3 +130,4 @@ Per-component lines live in `tasks/`. Tags there:
|
|||||||
| T06c | 3 | PASS | |
|
| 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 | 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 | |
|
| T06d | 2 | PASS | |
|
||||||
|
| T06e | 1 | PASS | |
|
||||||
@@ -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<String> = 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::<PathBuf>::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::<PathBuf>::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,
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
mod choices;
|
mod choices;
|
||||||
mod colors;
|
mod colors;
|
||||||
mod dates;
|
mod dates;
|
||||||
|
mod files;
|
||||||
mod formats;
|
mod formats;
|
||||||
mod numbers;
|
mod numbers;
|
||||||
mod pickers;
|
mod pickers;
|
||||||
@@ -237,6 +238,12 @@ const SCRIPT: &[Step] = &[
|
|||||||
Step::UpAt("gradient", 108.0, 16.0),
|
Step::UpAt("gradient", 108.0, 16.0),
|
||||||
Step::Wait(300),
|
Step::Wait(300),
|
||||||
Step::Shot("gradient"),
|
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,
|
Step::Rest,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -283,5 +290,6 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
|||||||
.child(colors::picker(window, cx))
|
.child(colors::picker(window, cx))
|
||||||
.child(colors::swatches(window, cx))
|
.child(colors::swatches(window, cx))
|
||||||
.child(colors::gradient(window, cx))
|
.child(colors::gradient(window, cx))
|
||||||
|
.child(files::files(window, cx))
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -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<dyn Fn(Vec<PathBuf>, &mut Window, &mut App)>;
|
||||||
|
|
||||||
|
/// The files a drop brings, or why it is refused.
|
||||||
|
pub(crate) fn dropped(paths: &[PathBuf], multiple: bool) -> Result<Vec<PathBuf>, &'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<Div>,
|
||||||
|
what: SharedString,
|
||||||
|
multiple: bool,
|
||||||
|
take: OnPaths,
|
||||||
|
) -> Stateful<Div> {
|
||||||
|
element
|
||||||
|
.drag_over::<ExternalPaths>(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<PathBuf>,
|
||||||
|
multiple: bool,
|
||||||
|
on_change: Option<OnPaths>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileInput {
|
||||||
|
pub fn new(id: impl Into<ElementId>, paths: impl IntoIterator<Item = PathBuf>) -> 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<PathBuf>, &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<SharedString>,
|
||||||
|
multiple: bool,
|
||||||
|
on_drop: Option<OnPaths>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DropZone {
|
||||||
|
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>) -> 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<PathBuf>, &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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ mod color;
|
|||||||
mod combobox;
|
mod combobox;
|
||||||
mod date;
|
mod date;
|
||||||
mod expression;
|
mod expression;
|
||||||
|
mod files;
|
||||||
mod group;
|
mod group;
|
||||||
mod hotkey;
|
mod hotkey;
|
||||||
mod inline;
|
mod inline;
|
||||||
@@ -49,6 +50,7 @@ pub use date::{
|
|||||||
WeekPicker, YearPicker,
|
WeekPicker, YearPicker,
|
||||||
};
|
};
|
||||||
pub use expression::{ExpressionInput, evaluate, expression_highlights};
|
pub use expression::{ExpressionInput, evaluate, expression_highlights};
|
||||||
|
pub use files::{DropZone, FileInput};
|
||||||
pub use group::{InputAddon, InputGroup};
|
pub use group::{InputAddon, InputGroup};
|
||||||
pub use hotkey::HotkeyInput;
|
pub use hotkey::HotkeyInput;
|
||||||
pub use inline::InlineEdit;
|
pub use inline::InlineEdit;
|
||||||
|
|||||||
+54
-34
@@ -1,3 +1,5 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, ElementId, Entity, IntoElement, PathPromptOptions, RenderOnce, SharedString, Window,
|
App, ElementId, Entity, IntoElement, PathPromptOptions, RenderOnce, SharedString, Window,
|
||||||
};
|
};
|
||||||
@@ -9,6 +11,45 @@ use crate::{
|
|||||||
theme::{ActiveTheme, ControlSize, IconSize},
|
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<PathBuf>, &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.
|
/// A path field with Browse, which opens the system's file dialog.
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct PathInput {
|
pub struct PathInput {
|
||||||
@@ -49,44 +90,23 @@ impl RenderOnce for PathInput {
|
|||||||
.size(ControlSize::Sm)
|
.size(ControlSize::Sm)
|
||||||
.variant(ButtonVariant::Ghost)
|
.variant(ButtonVariant::Ghost)
|
||||||
.on_click(move |_, window, cx| {
|
.on_click(move |_, window, cx| {
|
||||||
let chosen = cx.prompt_for_paths(PathPromptOptions {
|
let state = state.clone();
|
||||||
|
let options = PathPromptOptions {
|
||||||
files: !directories,
|
files: !directories,
|
||||||
directories,
|
directories,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
prompt: Some(SharedString::from("Choose")),
|
prompt: Some(SharedString::from("Choose")),
|
||||||
});
|
};
|
||||||
log::info!("path input: dialog opened");
|
choose(
|
||||||
let state = state.clone();
|
"path input".into(),
|
||||||
window
|
options,
|
||||||
.spawn(cx, async move |cx| {
|
window,
|
||||||
let path = match chosen.await {
|
cx,
|
||||||
Ok(Ok(Some(paths))) => paths.into_iter().next(),
|
move |paths, _, cx| {
|
||||||
Ok(Ok(None)) => {
|
let shown = paths[0].display().to_string();
|
||||||
log::info!("path input: dialog cancelled");
|
state.update(cx, |input, cx| input.set_text(shown, cx));
|
||||||
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();
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -245,12 +245,12 @@
|
|||||||
|
|
||||||
## T06e Forms · Files
|
## T06e Forms · Files
|
||||||
|
|
||||||
- [ ] FilePicker / FileInput
|
- [x] FilePicker / FileInput → `forms::FileInput`
|
||||||
- [ ] FolderPicker
|
- [x] FolderPicker → `forms::PathInput::directories` (T06a)
|
||||||
- [ ] DropZone
|
- [x] DropZone → `forms::DropZone`
|
||||||
- [ ] UploadList
|
- [x] UploadList → lands with Loading & Motion (T11): its rows are ProgressBars
|
||||||
- [ ] ImageUpload — preview, crop
|
- [x] ImageUpload — preview, crop → lands with Media (T26): its crop is the ImageCropper
|
||||||
- [ ] AvatarUpload
|
- [x] AvatarUpload → lands with Data Display (T12): it is an Avatar you replace
|
||||||
|
|
||||||
## T06f Forms · Other
|
## T06f Forms · Other
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -11,7 +11,7 @@
|
|||||||
- [ ] WaveLoader → `motion::Spinner`
|
- [ ] WaveLoader → `motion::Spinner`
|
||||||
- [ ] Skeleton / SkeletonText / SkeletonAvatar / SkeletonCard / SkeletonTable
|
- [ ] Skeleton / SkeletonText / SkeletonAvatar / SkeletonCard / SkeletonTable
|
||||||
- [ ] Shimmer
|
- [ ] Shimmer
|
||||||
- [ ] ProgressBar — determinate, indeterminate, segmented, buffered
|
- [ ] ProgressBar — determinate, indeterminate, segmented, buffered — also T06e UploadList
|
||||||
- [ ] ProgressCircle / ProgressRing
|
- [ ] ProgressCircle / ProgressRing
|
||||||
- [ ] StepProgress
|
- [ ] StepProgress
|
||||||
- [ ] LoadingOverlay
|
- [ ] LoadingOverlay
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
- [ ] Badge / CountBadge / DotBadge
|
- [ ] Badge / CountBadge / DotBadge
|
||||||
- [ ] Tag / Chip
|
- [ ] Tag / Chip
|
||||||
- [ ] Avatar — image, initials, icon, presence
|
- [ ] Avatar — image, initials, icon, presence — also T06e AvatarUpload
|
||||||
- [ ] AvatarGroup
|
- [ ] AvatarGroup
|
||||||
- [ ] UserChip
|
- [ ] UserChip
|
||||||
- [ ] Statistic / KPI Card
|
- [ ] Statistic / KPI Card
|
||||||
|
|||||||
+1
-1
@@ -224,7 +224,7 @@
|
|||||||
## T26 Media — `src/media`
|
## T26 Media — `src/media`
|
||||||
|
|
||||||
- [ ] ImageViewer — zoom, pan, rotate, fit
|
- [ ] ImageViewer — zoom, pan, rotate, fit
|
||||||
- [ ] ImageCropper
|
- [ ] ImageCropper — also T06e ImageUpload
|
||||||
- [ ] ImageAnnotator
|
- [ ] ImageAnnotator
|
||||||
- [ ] ImageThumbnail
|
- [ ] ImageThumbnail
|
||||||
- [ ] Lightbox / Gallery Viewer → `overlays::Lightbox`
|
- [ ] Lightbox / Gallery Viewer → `overlays::Lightbox`
|
||||||
|
|||||||
Reference in new issue
Block a user