T12a Data Display: badges, tags, avatars, statistics (review pending: codex 401)
This commit is contained in:
24 files changed
+1434
-83
No files matched your search
@@ -0,0 +1,163 @@
|
||||
use std::{path::PathBuf, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, ExternalPaths, InteractiveElement, IntoElement, ParentElement,
|
||||
PathPromptOptions, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
};
|
||||
|
||||
use super::{files::hovering, path::choose};
|
||||
use crate::{
|
||||
data_display::Avatar,
|
||||
primitives::{FocusRing, Icon, IconName, tab_stop},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
/// File kinds an avatar takes.
|
||||
const PICTURES: [&str; 6] = ["png", "jpg", "jpeg", "gif", "webp", "heic"];
|
||||
|
||||
/// The one picture a drop or a pick brings, or why it is refused.
|
||||
pub(crate) fn picture(paths: &[PathBuf]) -> Result<PathBuf, &'static str> {
|
||||
let [path] = paths else {
|
||||
return Err(if paths.is_empty() {
|
||||
"nothing came"
|
||||
} else {
|
||||
"one picture at a time"
|
||||
});
|
||||
};
|
||||
if !path.is_file() {
|
||||
return Err("files only, not folders");
|
||||
}
|
||||
let kind = path
|
||||
.extension()
|
||||
.and_then(|kind| kind.to_str())
|
||||
.map(str::to_lowercase);
|
||||
if !kind.is_some_and(|kind| PICTURES.contains(&kind.as_str())) {
|
||||
return Err("pictures only: png, jpg, gif, webp or heic");
|
||||
}
|
||||
Ok(path.clone())
|
||||
}
|
||||
|
||||
type OnPicture = Rc<dyn Fn(PathBuf, &mut Window, &mut App)>;
|
||||
|
||||
/// An avatar you replace: a hover shows a camera, a press or Enter opens the picture dialog, and a picture dropped on it works too. `on_change` gets its path.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AvatarUpload {
|
||||
id: ElementId,
|
||||
avatar: Avatar,
|
||||
on_change: Option<OnPicture>,
|
||||
}
|
||||
|
||||
impl AvatarUpload {
|
||||
pub fn new(id: impl Into<ElementId>, avatar: Avatar) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
avatar,
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_change(mut self, handler: impl Fn(PathBuf, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AvatarUpload {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
|
||||
let what = SharedString::from(format!("avatar upload {:?}", self.id));
|
||||
let take: OnPicture = {
|
||||
let (what, on_change) = (what.clone(), self.on_change);
|
||||
Rc::new(move |path, window, cx| {
|
||||
log::info!("{what}: took {}", path.display());
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(path, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let group = SharedString::from(format!("{:?}", self.id));
|
||||
let (pick, drop_in, what_drop) = (take.clone(), take, what.clone());
|
||||
div()
|
||||
.id(self.id)
|
||||
.group(group.clone())
|
||||
.relative()
|
||||
.flex_none()
|
||||
.rounded_full()
|
||||
.border_1()
|
||||
.border_color(gpui::transparent_black())
|
||||
.cursor_pointer()
|
||||
.track_focus(&focus)
|
||||
.focus_ring(cx)
|
||||
.child(self.avatar)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.inset_0()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded_full()
|
||||
.bg(theme.colors.media_backdrop.opacity(0.5))
|
||||
.opacity(0.0)
|
||||
.group_hover(group, |style| style.opacity(1.0))
|
||||
.child(
|
||||
Icon::new(IconName::Camera)
|
||||
.size(IconSize::Md)
|
||||
.color(theme.colors.on_media),
|
||||
),
|
||||
)
|
||||
.on_click(move |_, window, cx| {
|
||||
let pick = pick.clone();
|
||||
let what = what.clone();
|
||||
choose(
|
||||
what.clone(),
|
||||
PathPromptOptions {
|
||||
files: true,
|
||||
directories: false,
|
||||
multiple: false,
|
||||
prompt: Some("Choose".into()),
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
move |paths, window, cx| match picture(&paths) {
|
||||
Ok(path) => pick(path, window, cx),
|
||||
Err(reason) => log::warn!("{what}: refused a pick: {reason}"),
|
||||
},
|
||||
);
|
||||
})
|
||||
.drag_over::<ExternalPaths>(|style, paths, _, cx| {
|
||||
hovering(style, picture(paths.paths()).is_ok(), cx)
|
||||
})
|
||||
.on_drop(
|
||||
move |paths: &ExternalPaths, window, cx| match picture(paths.paths()) {
|
||||
Ok(path) => drop_in(path, window, cx),
|
||||
Err(reason) => log::info!("{what_drop}: refused a drop: {reason}"),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
use super::picture;
|
||||
|
||||
#[test]
|
||||
fn an_avatar_takes_one_picture_file() {
|
||||
let dir = std::env::temp_dir().join("ely-avatar-picture");
|
||||
fs::create_dir_all(&dir).expect("a temp dir");
|
||||
let (face, notes) = (dir.join("face.PNG"), dir.join("notes.txt"));
|
||||
fs::write(&face, b"png").expect("a picture");
|
||||
fs::write(¬es, b"text").expect("a note");
|
||||
assert_eq!(picture(std::slice::from_ref(&face)), Ok(face.clone()));
|
||||
assert_eq!(
|
||||
picture(&[notes]),
|
||||
Err("pictures only: png, jpg, gif, webp or heic")
|
||||
);
|
||||
assert_eq!(picture(&[face.clone(), face]), Err("one picture at a time"));
|
||||
assert_eq!(picture(&[dir]), Err("files only, not folders"));
|
||||
assert_eq!(picture(&[]), Err("nothing came"));
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@ pub(crate) fn dropped(paths: &[PathBuf], multiple: bool) -> Result<Vec<PathBuf>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
pub(super) fn hovering(style: StyleRefinement, takes: bool, cx: &App) -> StyleRefinement {
|
||||
let colors = &cx.theme().colors;
|
||||
if takes {
|
||||
style.border_color(colors.focus).bg(colors.selection)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
mod avatar;
|
||||
mod card;
|
||||
mod cascade;
|
||||
mod catalogs;
|
||||
@@ -43,6 +44,7 @@ mod transfer;
|
||||
mod unit;
|
||||
mod upload;
|
||||
|
||||
pub use avatar::AvatarUpload;
|
||||
pub use card::{CheckboxCard, RadioCard};
|
||||
pub use cascade::{Cascade, Cascader};
|
||||
pub use catalogs::FontPicker;
|
||||
|
||||
+6
-7
@@ -10,9 +10,9 @@ use super::{
|
||||
listbox::chosen,
|
||||
options::{OnValues, Pick, Popup, step},
|
||||
select::{Picker, measure_anchor, moved},
|
||||
tags::chip,
|
||||
};
|
||||
use crate::{
|
||||
data_display::Tag,
|
||||
primitives::{Icon, IconName, tab_stop},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
};
|
||||
@@ -125,14 +125,13 @@ impl RenderOnce for MultiSelect {
|
||||
.map(|(ix, choice)| {
|
||||
let (choices, selected, commit) =
|
||||
(choices.clone(), selected.clone(), commit.clone());
|
||||
chip(
|
||||
("chip-remove", ix).into(),
|
||||
Tag::new(
|
||||
(self.id.clone(), format!("chip-{ix}")),
|
||||
choice.label.clone(),
|
||||
Rc::new(move |window, cx| {
|
||||
commit(&chosen(&choices, &selected, ix, true), window, cx)
|
||||
}),
|
||||
cx,
|
||||
)
|
||||
.on_remove(move |window, cx| {
|
||||
commit(&chosen(&choices, &selected, ix, true), window, cx)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let (click, keys, rows, close) = (
|
||||
|
||||
+9
-47
@@ -1,15 +1,14 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, Context, Div, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
|
||||
Window, div, prelude::*,
|
||||
App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||
RenderOnce, SharedString, Styled, Subscription, Window, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{InputEvent, TextInput, options::Run, text::Backspace};
|
||||
use super::{InputEvent, TextInput, text::Backspace};
|
||||
use crate::{
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
data_display::Tag,
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
type OnChange = Rc<dyn Fn(Vec<SharedString>, &mut Window, &mut App)>;
|
||||
@@ -45,41 +44,6 @@ impl Tags {
|
||||
}
|
||||
}
|
||||
|
||||
/// A small label with an x that removes it.
|
||||
pub(crate) fn chip(id: ElementId, label: SharedString, remove: Run, cx: &App) -> Div {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.pl_2()
|
||||
.pr_1()
|
||||
.h(theme.control_height(ControlSize::Sm))
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(colors.hover)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(colors.fg)
|
||||
.child(label)
|
||||
.child(
|
||||
div()
|
||||
.id(id)
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.cursor_pointer()
|
||||
.hover(|style| style.bg(colors.active))
|
||||
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
|
||||
.on_click(move |_, window, cx| {
|
||||
cx.stop_propagation();
|
||||
remove(window, cx)
|
||||
})
|
||||
.child(
|
||||
Icon::new(IconName::X)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_muted),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last.
|
||||
#[derive(IntoElement)]
|
||||
pub struct TagInput {
|
||||
@@ -145,20 +109,18 @@ impl RenderOnce for TagInput {
|
||||
let focus = input.read(cx).focus().clone();
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let owner = self.id.clone();
|
||||
let chips = self.tags.iter().enumerate().map(|(ix, tag)| {
|
||||
let (tags, on_change) = (self.tags.clone(), self.on_change.clone());
|
||||
chip(
|
||||
("tag-remove", ix).into(),
|
||||
tag.clone(),
|
||||
Rc::new(move |window, cx| {
|
||||
Tag::new((owner.clone(), format!("tag-{ix}")), tag.clone()).on_remove(
|
||||
move |window, cx| {
|
||||
let mut next = tags.clone();
|
||||
next.remove(ix);
|
||||
log::info!("tag input: removed one, {} left", next.len());
|
||||
if let Some(on_change) = &on_change {
|
||||
on_change(next, window, cx);
|
||||
}
|
||||
}),
|
||||
cx,
|
||||
},
|
||||
)
|
||||
});
|
||||
let (tags, on_change, empty_check) =
|
||||
|
||||
Reference in new issue
Block a user