Files · Items: one icon map, file icon, file row and file grid
The crate had two maps from a file's name to its icon, in chat and in the file tree, and they disagreed. One map now lives in primitives, over the Lucide file-* icons, and chat, agent, the file tree and the directory listing use it. FileIcon draws it for a file or a folder. FileItem is a file's row, a ListItem with its size and when it changed. FileList is the directory listing. FileGrid lays tiles in the order given, with a cursor seeded from the owner's pick: arrows by tile and by row, Home, End, Enter and a double press to open. The count of what fits a width has one home, layout's columns_for, and fit builds on it.
No files matched your search
@@ -112,6 +112,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- gpui 0.2.2 keeps a no-wrap line's first measure, taken at full width inside flex and scroll boxes, so `.truncate()` clips there instead of ending in an ellipsis. A line that may overflow is a `typography::Ellipsis`.
|
||||
- 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.
|
||||
- gpui's `img` takes its picture's pixel size for any `Auto` side, and its aspect ratio beats percent heights in flow. `Image` pins it absolute at full size, so an `Image` needs a sized box.
|
||||
- A file's icon comes from `primitives::file_icon`, one map from name to icon for every chapter; `files::FileIcon` draws it. What fits across a width comes from `layout::columns_for`, and `layout::fit` adds the inset that centers it.
|
||||
- Badges, tags and avatars live in `data_display`; forms and shell draw theirs from there, and `data_display` imports neither. Avatars take square pictures.
|
||||
- A picture drawn in code reaches gpui as a `RenderImage`. A host that replaces one frees the old through `App::drop_image`, or every frame stays in the sprite atlas (the gallery's model).
|
||||
- `img()` keeps loading state, and moves an animated picture's frames, only with an id. Content masks are rectangles, so rounded corners survive only when the image fills its box without cropping.
|
||||
|
||||
@@ -93,8 +93,11 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T26b Media · Crop and annotate
|
||||
- [x] T26c Media · Video
|
||||
- [x] T26d Media · Audio and controls
|
||||
- [ ] T26e Media · Capture and devices
|
||||
- [ ] T27 Files
|
||||
- [x] T26e Media · Capture and devices
|
||||
- [ ] T27a Files · Items
|
||||
- [ ] T27b Files · Explorer
|
||||
- [ ] T27c Files · Find and look
|
||||
- [ ] T27d Files · Transfers
|
||||
- [ ] T28 Messaging
|
||||
- [ ] T29 Mail
|
||||
- [ ] T30 Calendar
|
||||
@@ -317,3 +320,4 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
| T26d | 1 | FAIL | the playlist's keys started from the first row, not the song under way; the waveform's bar fit sat untested in paint |
|
||||
| T26d | 2 | PASS | |
|
||||
| T26e | 1 | FAIL | an option list had no width limit, so a long device name ran it past the window |
|
||||
| T26e | 2 | PASS | |
|
||||
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-archive"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M13.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v11.5" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M8 12v-1" />
|
||||
<path d="M8 18v-2" />
|
||||
<path d="M8 7V6" />
|
||||
<circle cx="8" cy="20" r="2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 557 B |
@@ -0,0 +1,17 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-audio"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 6.835V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2h-.343" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M2 19a2 2 0 0 1 4 0v1a2 2 0 0 1-4 0v-4a6 6 0 0 1 12 0v4a2 2 0 0 1-4 0v-1a2 2 0 0 1 4 0" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 550 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-code"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M10 12.5 8 15l2 2.5" />
|
||||
<path d="m14 12.5 2 2.5-2 2.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 524 B |
@@ -0,0 +1,26 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-cog"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 8a1 1 0 0 1-1-1V2a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8z" />
|
||||
<path d="M20 8v12a2 2 0 0 1-2 2h-4.182" />
|
||||
<path d="m3.305 19.53.923-.382" />
|
||||
<path d="M4 10.592V4a2 2 0 0 1 2-2h8" />
|
||||
<path d="m4.228 16.852-.924-.383" />
|
||||
<path d="m5.852 15.228-.383-.923" />
|
||||
<path d="m5.852 20.772-.383.924" />
|
||||
<path d="m8.148 15.228.383-.923" />
|
||||
<path d="m8.53 21.696-.382-.924" />
|
||||
<path d="m9.773 16.852.922-.383" />
|
||||
<path d="m9.773 19.148.922.383" />
|
||||
<circle cx="7" cy="18" r="3" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 806 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-image"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 552 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-json"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M10 12a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1" />
|
||||
<path d="M14 18a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1 1 1 0 0 1-1-1v-1a1 1 0 0 0-1-1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 619 B |
@@ -0,0 +1,20 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-spreadsheet"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M8 13h2" />
|
||||
<path d="M14 13h2" />
|
||||
<path d="M8 17h2" />
|
||||
<path d="M14 17h2" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 554 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-terminal"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="m8 16 2-2-2-2" />
|
||||
<path d="M12 18h4" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1,19 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-type"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="M11 18h2" />
|
||||
<path d="M12 12v6" />
|
||||
<path d="M9 13v-.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5v.5" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 564 B |
@@ -0,0 +1,18 @@
|
||||
<!-- @license lucide-static v1.48.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-file-video-camera"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 12V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2" />
|
||||
<path d="M14 2v5a1 1 0 0 0 1 1h5" />
|
||||
<path d="m10 17.843 3.033-1.755a.64.64 0 0 1 .967.56v4.704a.65.65 0 0 1-.967.56L10 20.157" />
|
||||
<rect width="7" height="6" x="3" y="16" rx="1" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 594 B |
@@ -0,0 +1,169 @@
|
||||
use ely_gpui_component::{
|
||||
files::{FileGrid, FileIcon, FileItem},
|
||||
lists::{DirEntry, DirectoryListing, SelectableList},
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
};
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
use jiff::{Timestamp, ToSpan};
|
||||
|
||||
use super::Page;
|
||||
use crate::{
|
||||
probe::probe,
|
||||
script::Step,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 27,
|
||||
slug: "files",
|
||||
title: "Files",
|
||||
summary: "Files and folders: their icons, rows and tiles, and the listings, searches and transfers around them.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("files-grid", 150.0, 40.0),
|
||||
Step::UpAt("files-grid", 150.0, 40.0),
|
||||
Step::Key("right"),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("grid-picked"),
|
||||
];
|
||||
|
||||
/// A project's entries, changed a few hours to a few weeks ago.
|
||||
fn entries() -> Vec<DirEntry> {
|
||||
let now = Timestamp::now();
|
||||
let ago = |hours: i64| now - hours.hours();
|
||||
vec![
|
||||
DirEntry::folder("Design", ago(3)),
|
||||
DirEntry::folder("Recordings", ago(30)),
|
||||
DirEntry::file("Brief.md", 4_800, ago(2)),
|
||||
DirEntry::file("Budget.csv", 18_300, ago(26)),
|
||||
DirEntry::file("Atrium.jpg", 2_140_000, ago(50)),
|
||||
DirEntry::file("Walkthrough.mov", 184_000_000, ago(120)),
|
||||
DirEntry::file("main.rs", 9_200, ago(1)),
|
||||
DirEntry::file("Archive.zip", 48_000_000, ago(700)),
|
||||
]
|
||||
}
|
||||
|
||||
fn icons(cx: &mut App) -> impl IntoElement + use<> {
|
||||
let theme = cx.theme();
|
||||
let (small, muted) = (theme.text_size(TextSize::Xs), theme.colors.fg_muted);
|
||||
let names = [
|
||||
"Brief.md",
|
||||
"main.rs",
|
||||
"build.sh",
|
||||
"config.toml",
|
||||
"data.json",
|
||||
"Atrium.jpg",
|
||||
"Theme.mp3",
|
||||
"Clip.mov",
|
||||
"Archive.zip",
|
||||
"Budget.csv",
|
||||
"Inter.ttf",
|
||||
"Makefile",
|
||||
];
|
||||
let tile = |icon: FileIcon, name: &'static str| {
|
||||
div()
|
||||
.w(px(88.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_1p5()
|
||||
.child(icon.size(IconSize::Xl))
|
||||
.child(div().text_size(small).text_color(muted).child(name))
|
||||
};
|
||||
section(
|
||||
"FileIcon",
|
||||
"A file's icon by its name's extension: code, text, pictures, sound, video, archives, tables, fonts and scripts; a folder shut or open.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-icons",
|
||||
div()
|
||||
.w(px(640.))
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_y_4()
|
||||
.child(tile(FileIcon::folder(false), "Folder"))
|
||||
.child(tile(FileIcon::folder(true), "Open"))
|
||||
.children(names.map(|name| tile(FileIcon::file(name), name))),
|
||||
))
|
||||
}
|
||||
|
||||
fn items(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let picked = keep("files-items", || SharedString::from("Brief.md"), window, cx);
|
||||
let now = picked.read(cx).clone();
|
||||
let pick = picked.clone();
|
||||
let list =
|
||||
entries()
|
||||
.into_iter()
|
||||
.take(5)
|
||||
.fold(SelectableList::new("files-items"), |list, entry| {
|
||||
let key = entry.name().clone();
|
||||
list.row(
|
||||
key.clone(),
|
||||
FileItem::new(SharedString::from(format!("files-item-{key}")), entry).into(),
|
||||
)
|
||||
});
|
||||
section(
|
||||
"FileItem",
|
||||
"A file or folder as a row: its icon and name over its size and when it changed.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-items",
|
||||
div()
|
||||
.w(px(420.))
|
||||
.child(list.selected([now]).on_change(move |keys, _, cx| {
|
||||
if let Some(key) = keys.first() {
|
||||
change(&pick, cx, |now| *now = key.clone())
|
||||
}
|
||||
})),
|
||||
))
|
||||
}
|
||||
|
||||
fn listing(cx: &mut App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"FileList",
|
||||
"A folder's entries in columns, as lists::DirectoryListing draws them: sort by a column, climb the path, open with Enter or a double press.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"files-listing",
|
||||
div().w(px(640.)).h(px(360.)).child(
|
||||
DirectoryListing::new("files-listing", ["Home", "Projects", "Atrium"], entries())
|
||||
.on_open(|entry, _, _| log::info!("gallery: open {}", entry.name()))
|
||||
.on_climb(|level, _, _| log::info!("gallery: climb to {level}")),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn grid(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let picked = keep("files-grid", || None::<SharedString>, window, cx);
|
||||
let now = picked.read(cx).clone();
|
||||
let pick = picked.clone();
|
||||
let grid = FileGrid::new("files-grid", entries())
|
||||
.on_select(move |name, _, cx| change(&pick, cx, |now| *now = Some(name.clone())))
|
||||
.on_open(|entry, _, _| log::info!("gallery: open {}", entry.name()));
|
||||
let grid = match now {
|
||||
Some(name) => grid.selected(name),
|
||||
None => grid,
|
||||
};
|
||||
section(
|
||||
"FileGrid",
|
||||
"Files and folders as tiles. A press picks one and a double press opens it; with focus the arrows move by tile and by row, and Enter opens.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("files-grid", div().w(px(640.)).child(grid)))
|
||||
}
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(icons(cx))
|
||||
.child(items(window, cx))
|
||||
.child(listing(cx))
|
||||
.child(grid(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod debug;
|
||||
mod documents;
|
||||
mod editor;
|
||||
mod feedback;
|
||||
mod files;
|
||||
mod finance;
|
||||
mod forms;
|
||||
mod generative;
|
||||
@@ -67,6 +68,7 @@ pub const ALL: &[Page] = &[
|
||||
agent::PAGE,
|
||||
generative::PAGE,
|
||||
media::PAGE,
|
||||
files::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -8,11 +8,10 @@ use gpui::{
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
chat::file_icon,
|
||||
collab::Decision,
|
||||
forms::OnFlag,
|
||||
git::{DiffStat, DiffViewer, stat},
|
||||
primitives::{Disclosure, FocusRing, Icon, IconName},
|
||||
primitives::{Disclosure, FocusRing, Icon, IconName, file_icon},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
@@ -5,13 +5,12 @@ use gpui::{
|
||||
Styled, Window, div, relative,
|
||||
};
|
||||
|
||||
use super::media::file_icon;
|
||||
use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
data_display::Tag,
|
||||
documents::source,
|
||||
forms::{Pick, Run},
|
||||
primitives::{Icon, IconName, Image},
|
||||
primitives::{Icon, IconName, Image, file_icon},
|
||||
theme::{ActiveTheme, AvatarSize, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, format::file_size},
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{
|
||||
buttons::{ButtonVariant, IconButton},
|
||||
documents::source,
|
||||
forms::{Pick, Run},
|
||||
primitives::{Icon, IconName, Image},
|
||||
primitives::{Icon, IconName, Image, file_icon},
|
||||
theme::{ActiveTheme, AvatarSize, ContainerSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, format::file_size},
|
||||
};
|
||||
@@ -205,16 +205,6 @@ impl RenderOnce for ImageGrid {
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon a file's name suggests.
|
||||
pub(crate) fn file_icon(name: &str) -> IconName {
|
||||
let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase());
|
||||
match extension.as_deref() {
|
||||
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "heic") => IconName::Image,
|
||||
Some("pdf" | "md" | "txt" | "doc" | "docx" | "rtf" | "pages") => IconName::FileText,
|
||||
_ => IconName::File,
|
||||
}
|
||||
}
|
||||
|
||||
/// A file in a message: its kind's icon, its name and size, and a way to download it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FileMessage {
|
||||
@@ -412,15 +402,3 @@ impl RenderOnce for LinkPreviewCard {
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_file_name_picks_its_icon() {
|
||||
assert_eq!(file_icon("lift.PNG"), IconName::Image);
|
||||
assert_eq!(file_icon("notes.md"), IconName::FileText);
|
||||
assert_eq!(file_icon("archive"), IconName::File);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@ pub use code::CodeBlock;
|
||||
pub use composer::{AttachmentButton, DragDropOverlay, InputHint, PromptInput, SendButton};
|
||||
pub use conversations::{Conversation, ConversationItem, ConversationList, new_chat_button};
|
||||
pub use list::{DateSeparator, MessageList, ScrollToBottomButton};
|
||||
pub(crate) use media::file_icon;
|
||||
pub use media::{FileMessage, ImageGrid, ImageMessage, LinkPreviewCard};
|
||||
pub use message::{
|
||||
ChatContainer, MessageAvatar, MessageBubble, MessageFooter, MessageHeader, Role,
|
||||
|
||||
@@ -7,13 +7,12 @@ use gpui::{
|
||||
};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use super::media::file_icon;
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
data_display::Meter,
|
||||
forms::{CheckState, Checkbox, Choice, OnFlag, OnValue, Pick, RadioGroup, Run},
|
||||
overlays::Dialog,
|
||||
primitives::{FocusRing, Icon, IconName},
|
||||
primitives::{FocusRing, Icon, IconName, file_icon},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, RelativeTime, format::file_size, tabular},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
|
||||
rems, transparent_black,
|
||||
};
|
||||
|
||||
use super::FileIcon;
|
||||
use crate::{
|
||||
forms::OnValue,
|
||||
layout::{columns_for, seeded::use_seeded},
|
||||
lists::DirEntry,
|
||||
primitives::{FocusRing, tab_stop},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::LEADING,
|
||||
};
|
||||
|
||||
type OnEntry = Rc<dyn Fn(&DirEntry, &mut Window, &mut App)>;
|
||||
|
||||
/// The tile a key moves to from `at` in `count` tiles laid `columns` a row: across by one, up and down by a row, Home and End to the ends; down from over a short last row lands on its last tile.
|
||||
pub(crate) fn stepped(at: usize, count: usize, columns: usize, key: &str) -> Option<usize> {
|
||||
let last = count.checked_sub(1)?;
|
||||
match key {
|
||||
"left" => at.checked_sub(1),
|
||||
"right" => (at < last).then_some(at + 1),
|
||||
"up" => at.checked_sub(columns),
|
||||
"down" if at + columns <= last => Some(at + columns),
|
||||
"down" => (at / columns < last / columns).then_some(last),
|
||||
"home" => Some(0),
|
||||
"end" => Some(last),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Files and folders as tiles, in the order given: a large icon over the name in two lines at most. A press picks a tile and a double press opens it; with focus the arrows move by tile and by row, Home and End go to the ends, and Enter opens.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FileGrid {
|
||||
id: ElementId,
|
||||
entries: Vec<DirEntry>,
|
||||
selected: Option<SharedString>,
|
||||
on_select: Option<OnValue>,
|
||||
on_open: Option<OnEntry>,
|
||||
}
|
||||
|
||||
impl FileGrid {
|
||||
pub fn new(id: impl Into<ElementId>, entries: impl IntoIterator<Item = DirEntry>) -> Self {
|
||||
let entries: Vec<DirEntry> = entries.into_iter().collect();
|
||||
for (ix, entry) in entries.iter().enumerate() {
|
||||
assert!(
|
||||
!entries[..ix]
|
||||
.iter()
|
||||
.any(|other| other.name() == entry.name()),
|
||||
"{} twice",
|
||||
entry.name()
|
||||
);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
entries,
|
||||
selected: None,
|
||||
on_select: None,
|
||||
on_open: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entry picked, by name.
|
||||
pub fn selected(mut self, name: impl Into<SharedString>) -> Self {
|
||||
let name = name.into();
|
||||
assert!(
|
||||
self.entries.iter().any(|entry| *entry.name() == name),
|
||||
"no entry {name}"
|
||||
);
|
||||
self.selected = Some(name);
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets the name of the entry picked.
|
||||
pub fn on_select(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_select = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_open(mut self, handler: impl Fn(&DirEntry, &mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FileGrid {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
|
||||
let cursor = use_seeded((self.id.clone(), "cursor"), self.selected, window, cx);
|
||||
let width = window.use_keyed_state((self.id.clone(), "width"), cx, |_, _| Pixels::ZERO);
|
||||
let entries: Rc<[DirEntry]> = self.entries.into();
|
||||
let count = entries.len();
|
||||
let at = cursor
|
||||
.read(cx)
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(|name| entries.iter().position(|entry| entry.name() == name));
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rem = window.rem_size();
|
||||
let (tile, gap) = (theme.files().tile, rems(0.5));
|
||||
let columns =
|
||||
columns_for(*width.read(cx), tile.to_pixels(rem), gap.to_pixels(rem)) as usize;
|
||||
let pick = {
|
||||
let (id, entries, cursor, on_select) = (
|
||||
self.id.clone(),
|
||||
entries.clone(),
|
||||
cursor.clone(),
|
||||
self.on_select,
|
||||
);
|
||||
Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
|
||||
let name = entries[ix].name().clone();
|
||||
cursor.update(cx, |cursor, cx| {
|
||||
cursor.value = Some(name.clone());
|
||||
cx.notify();
|
||||
});
|
||||
log::info!("file grid {id:?}: picked {name}");
|
||||
if let Some(on_select) = &on_select {
|
||||
on_select(&name, window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let open = {
|
||||
let (id, entries, on_open) = (self.id.clone(), entries.clone(), self.on_open);
|
||||
Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
|
||||
log::info!("file grid {id:?}: open {}", entries[ix].name());
|
||||
if let Some(on_open) = &on_open {
|
||||
on_open(&entries[ix], window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let tiles = entries.iter().enumerate().map(|(ix, entry)| {
|
||||
let (pick, open) = (pick.clone(), open.clone());
|
||||
div()
|
||||
.id((self.id.clone(), format!("tile-{ix}")))
|
||||
.flex_none()
|
||||
.w(tile)
|
||||
.flex()
|
||||
.flex_col()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_1()
|
||||
.py_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.cursor_pointer()
|
||||
.when(at == Some(ix), |tile| tile.bg(colors.active))
|
||||
.when(at != Some(ix), |tile| {
|
||||
tile.hover(|style| style.bg(colors.hover))
|
||||
})
|
||||
.child(FileIcon::entry(entry).size(IconSize::Xxl))
|
||||
.child(
|
||||
div()
|
||||
.w_full()
|
||||
.text_center()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.line_height(relative(LEADING))
|
||||
.text_color(colors.fg)
|
||||
.line_clamp(2)
|
||||
.child(entry.name().clone()),
|
||||
)
|
||||
.on_click(move |event, window, cx| {
|
||||
pick(ix, window, cx);
|
||||
if event.click_count() == 2 {
|
||||
open(ix, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
let tiles: Vec<_> = tiles.collect();
|
||||
let measured = width.clone();
|
||||
div()
|
||||
.id(self.id)
|
||||
.debug_selector(|| "file-grid".into())
|
||||
.relative()
|
||||
.w_full()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap(gap)
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(transparent_black())
|
||||
.track_focus(&focus)
|
||||
.focus_ring(cx)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
if *measured.read(cx) != bounds.size.width {
|
||||
measured.update(cx, |width, cx| {
|
||||
*width = bounds.size.width;
|
||||
cx.notify();
|
||||
});
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
.children(tiles)
|
||||
.on_key_down(move |event, window, cx| {
|
||||
let held = &event.keystroke.modifiers;
|
||||
if held.platform || held.control {
|
||||
return;
|
||||
}
|
||||
let key = event.keystroke.key.as_str();
|
||||
if key == "enter" {
|
||||
if let Some(at) = at {
|
||||
cx.stop_propagation();
|
||||
open(at, window, cx);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let to = match at {
|
||||
Some(at) => stepped(at, count, columns, key),
|
||||
None if key == "end" => count.checked_sub(1),
|
||||
None => stepped(0, count, columns, "home")
|
||||
.filter(|_| matches!(key, "left" | "right" | "up" | "down" | "home")),
|
||||
};
|
||||
if let Some(to) = to {
|
||||
cx.stop_propagation();
|
||||
pick(to, window, cx);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::stepped;
|
||||
|
||||
#[test]
|
||||
fn keys_move_across_by_one_and_down_by_a_row() {
|
||||
assert_eq!(stepped(2, 7, 3, "right"), Some(3));
|
||||
assert_eq!(stepped(0, 7, 3, "left"), None);
|
||||
assert_eq!(stepped(1, 7, 3, "down"), Some(4));
|
||||
assert_eq!(stepped(4, 7, 3, "down"), Some(6), "onto the short last row");
|
||||
assert_eq!(stepped(6, 7, 3, "down"), None);
|
||||
assert_eq!(stepped(5, 7, 3, "up"), Some(2));
|
||||
assert_eq!(stepped(1, 7, 3, "up"), None);
|
||||
assert_eq!(stepped(3, 7, 3, "end"), Some(6));
|
||||
assert_eq!(stepped(0, 0, 3, "home"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
use gpui::{App, IntoElement, RenderOnce, Window};
|
||||
|
||||
use crate::{
|
||||
lists::DirEntry,
|
||||
primitives::{Icon, IconName, file_icon},
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
/// A file's icon by its name's extension, or a folder's, open or shut.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FileIcon {
|
||||
icon: IconName,
|
||||
size: IconSize,
|
||||
}
|
||||
|
||||
impl FileIcon {
|
||||
pub fn file(name: impl AsRef<str>) -> Self {
|
||||
Self {
|
||||
icon: file_icon(name.as_ref()),
|
||||
size: IconSize::Md,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn folder(open: bool) -> Self {
|
||||
Self {
|
||||
icon: if open {
|
||||
IconName::FolderOpen
|
||||
} else {
|
||||
IconName::Folder
|
||||
},
|
||||
size: IconSize::Md,
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon for a listing's entry: a shut folder, or the file's by its name.
|
||||
pub fn entry(entry: &DirEntry) -> Self {
|
||||
match entry.is_folder() {
|
||||
true => Self::folder(false),
|
||||
false => Self::file(entry.name()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn size(mut self, size: IconSize) -> Self {
|
||||
self.size = size;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FileIcon {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
Icon::new(self.icon)
|
||||
.size(self.size)
|
||||
.color(cx.theme().colors.fg_muted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use gpui::{App, ElementId, IntoElement, RenderOnce, Window};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use super::FileIcon;
|
||||
use crate::{
|
||||
lists::{DirEntry, ListItem},
|
||||
typography::format,
|
||||
};
|
||||
|
||||
/// A file's size and when it changed, or a folder's word and when it changed.
|
||||
pub(crate) fn detail(entry: &DirEntry, now: Timestamp) -> String {
|
||||
let when = format::relative(entry.modified(), now);
|
||||
match entry.size() {
|
||||
Some(bytes) => format!("{} · {when}", format::file_size(bytes, false)),
|
||||
None => format!("Folder · {when}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file or folder as a row: its icon and name over its size and when it changed. As a `ListItem`, it goes into a `SelectableList`.
|
||||
#[derive(IntoElement)]
|
||||
pub struct FileItem {
|
||||
id: ElementId,
|
||||
entry: DirEntry,
|
||||
}
|
||||
|
||||
impl FileItem {
|
||||
pub fn new(id: impl Into<ElementId>, entry: DirEntry) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
entry,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<FileItem> for ListItem {
|
||||
fn from(item: FileItem) -> Self {
|
||||
let detail = detail(&item.entry, Timestamp::now());
|
||||
ListItem::new(item.id, item.entry.name().clone())
|
||||
.leading(FileIcon::entry(&item.entry))
|
||||
.description(detail)
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for FileItem {
|
||||
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
|
||||
ListItem::from(self)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod grid;
|
||||
mod icon;
|
||||
mod item;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use grid::FileGrid;
|
||||
pub use icon::FileIcon;
|
||||
pub use item::FileItem;
|
||||
@@ -0,0 +1,158 @@
|
||||
use gpui::{
|
||||
Context, Entity, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, ParentElement,
|
||||
Render, SharedString, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
|
||||
};
|
||||
use jiff::{Timestamp, ToSpan};
|
||||
|
||||
use super::{FileGrid, item::detail};
|
||||
use crate::{lists::DirEntry, primitives::FocusNext, theme::Theme};
|
||||
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn then() -> Timestamp {
|
||||
Timestamp::from_second(1_700_000_000).expect("a time")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_row_says_a_files_size_and_a_folders_kind_with_when_each_changed() {
|
||||
let now = then() + 2.hours();
|
||||
assert_eq!(
|
||||
detail(&DirEntry::file("a.txt", 2_400, then()), now),
|
||||
"2.4 KB · 2 hours ago"
|
||||
);
|
||||
assert_eq!(
|
||||
detail(&DirEntry::folder("Docs", then()), now),
|
||||
"Folder · 2 hours ago"
|
||||
);
|
||||
}
|
||||
|
||||
/// Seven files in a grid three tiles across, and what it picked and opened.
|
||||
struct Tiled {
|
||||
selected: Option<SharedString>,
|
||||
picked: Vec<SharedString>,
|
||||
opened: Vec<SharedString>,
|
||||
}
|
||||
|
||||
impl Render for Tiled {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let (pick, open) = (cx.entity(), cx.entity());
|
||||
let names = ["a", "b", "c", "d", "e", "f", "g"];
|
||||
let grid = FileGrid::new("grid", names.map(|name| DirEntry::file(name, 10, then())))
|
||||
.on_select(move |name, _, cx| pick.update(cx, |view, _| view.picked.push(name.clone())))
|
||||
.on_open(move |entry, _, cx| {
|
||||
open.update(cx, |view, _| view.opened.push(entry.name().clone()))
|
||||
});
|
||||
let grid = match self.selected.clone() {
|
||||
Some(name) => grid.selected(name),
|
||||
None => grid,
|
||||
};
|
||||
div().w(px(320.0)).child(grid)
|
||||
}
|
||||
}
|
||||
|
||||
fn tiled<'a>(
|
||||
selected: Option<&'static str>,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<Tiled>, &'a mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let selected = selected.map(|name| SharedString::from(name.to_string()));
|
||||
let (view, cx) = cx.add_window_view(move |_, _| Tiled {
|
||||
selected,
|
||||
picked: Vec::new(),
|
||||
opened: Vec::new(),
|
||||
});
|
||||
cx.update(|window, _| window.activate_window());
|
||||
settle(cx);
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
fn heard(
|
||||
view: &Entity<Tiled>,
|
||||
cx: &mut VisualTestContext,
|
||||
) -> (Vec<SharedString>, Vec<SharedString>) {
|
||||
view.read_with(cx, |view, _| (view.picked.clone(), view.opened.clone()))
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_arrows_move_by_tile_and_by_row_and_enter_opens(cx: &mut TestAppContext) {
|
||||
let (view, cx) = tiled(None, cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
for key in ["right", "right", "down", "down", "cmd-left"] {
|
||||
press(key, cx);
|
||||
}
|
||||
press("enter", cx);
|
||||
let (picked, opened) = heard(&view, cx);
|
||||
assert_eq!(picked, ["a", "b", "e", "g"], "a command key passes");
|
||||
assert_eq!(opened, ["g"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_keys_start_from_the_tile_picked(cx: &mut TestAppContext) {
|
||||
let (view, cx) = tiled(Some("c"), cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
press("down", cx);
|
||||
assert_eq!(heard(&view, cx).0, ["f"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_picks_a_tile_and_a_double_press_opens_it(cx: &mut TestAppContext) {
|
||||
let (view, cx) = tiled(None, cx);
|
||||
let grid = cx.debug_bounds("file-grid").expect("the grid draws");
|
||||
let first = grid.origin + point(px(12.0), px(8.0));
|
||||
cx.simulate_click(first, Modifiers::none());
|
||||
settle(cx);
|
||||
assert_eq!(heard(&view, cx), (vec!["a".into()], vec![]));
|
||||
cx.simulate_event(gpui::MouseDownEvent {
|
||||
button: gpui::MouseButton::Left,
|
||||
position: first,
|
||||
modifiers: Modifiers::none(),
|
||||
click_count: 2,
|
||||
first_mouse: false,
|
||||
});
|
||||
cx.simulate_event(gpui::MouseUpEvent {
|
||||
button: gpui::MouseButton::Left,
|
||||
position: first,
|
||||
modifiers: Modifiers::none(),
|
||||
click_count: 2,
|
||||
});
|
||||
settle(cx);
|
||||
assert_eq!(heard(&view, cx).1, ["a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "a twice")]
|
||||
fn a_name_is_listed_once() {
|
||||
let _ = FileGrid::new(
|
||||
"grid",
|
||||
["a", "a"].map(|name| DirEntry::file(name, 1, then())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "no entry z")]
|
||||
fn the_pick_is_one_of_the_entries() {
|
||||
let _ = FileGrid::new("grid", [DirEntry::file("a", 1, then())]).selected("z");
|
||||
}
|
||||
@@ -5,11 +5,18 @@ use gpui::{
|
||||
use smallvec::SmallVec;
|
||||
|
||||
/// Columns for `width`, each at least `min`, `gap` apart.
|
||||
fn columns_for(width: Pixels, min: Pixels, gap: Pixels) -> u16 {
|
||||
pub(crate) fn columns_for(width: Pixels, min: Pixels, gap: Pixels) -> u16 {
|
||||
let fit = ((width + gap) / (min + gap)).floor();
|
||||
fit.clamp(1.0, f32::from(u16::MAX)) as u16
|
||||
}
|
||||
|
||||
/// How many items `item` wide with `gap` between fit across `width`, at least one, and the inset that centers them.
|
||||
pub(crate) fn fit(width: Pixels, item: Pixels, gap: Pixels) -> (usize, Pixels) {
|
||||
let count = columns_for(width, item, gap) as usize;
|
||||
let used = (item + gap) * count as f32 - gap;
|
||||
(count, ((width - used) / 2.0).max(Pixels::ZERO))
|
||||
}
|
||||
|
||||
/// Index of the shortest column; ties go left.
|
||||
fn shortest(heights: &[Pixels]) -> usize {
|
||||
heights
|
||||
@@ -174,7 +181,14 @@ impl RenderOnce for Masonry {
|
||||
mod tests {
|
||||
use gpui::px;
|
||||
|
||||
use super::{columns_for, shortest};
|
||||
use super::{columns_for, fit, shortest};
|
||||
|
||||
#[test]
|
||||
fn what_fits_sits_centered() {
|
||||
assert_eq!(fit(px(100.0), px(3.0), px(2.0)), (20, px(1.0)));
|
||||
assert_eq!(fit(px(98.0), px(3.0), px(2.0)), (20, px(0.0)));
|
||||
assert_eq!(fit(px(2.0), px(3.0), px(2.0)), (1, px(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_fit_the_width() {
|
||||
|
||||
@@ -19,6 +19,7 @@ pub use collapse::{Accordion, AccordionItem, Collapsible};
|
||||
pub use dock::{Dock, DockLayout, DockPanel, DockSide, Spot};
|
||||
pub use floating::FloatingPanel;
|
||||
pub use grid::{Masonry, SimpleGrid};
|
||||
pub(crate) use grid::{columns_for, fit};
|
||||
pub use page::{AppShell, MasterDetail, Page};
|
||||
pub use panes::{PaneGroup, PaneId, PaneLayout};
|
||||
pub(crate) use scroll::bring_into_view;
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod debug;
|
||||
pub mod documents;
|
||||
pub mod editor;
|
||||
pub mod feedback;
|
||||
pub mod files;
|
||||
pub mod finance;
|
||||
pub mod forms;
|
||||
pub mod generative;
|
||||
|
||||
@@ -9,7 +9,7 @@ use jiff::Timestamp;
|
||||
use super::{ListItem, SelectableList};
|
||||
use crate::{
|
||||
navigation::{Breadcrumb, Crumb},
|
||||
primitives::{Icon, IconName},
|
||||
primitives::{Icon, IconName, file_icon},
|
||||
theme::{ActiveTheme, IconSize, TextSize},
|
||||
typography::{format, tabular},
|
||||
};
|
||||
@@ -46,6 +46,15 @@ impl DirEntry {
|
||||
pub fn is_folder(&self) -> bool {
|
||||
self.size.is_none()
|
||||
}
|
||||
|
||||
/// A file's size in bytes; none for a folder.
|
||||
pub fn size(&self) -> Option<u64> {
|
||||
self.size
|
||||
}
|
||||
|
||||
pub fn modified(&self) -> Timestamp {
|
||||
self.modified
|
||||
}
|
||||
}
|
||||
|
||||
/// A column a listing sorts by.
|
||||
@@ -223,7 +232,7 @@ impl RenderOnce for DirectoryListing {
|
||||
Icon::new(if entry.is_folder() {
|
||||
IconName::Folder
|
||||
} else {
|
||||
IconName::File
|
||||
file_icon(&entry.name)
|
||||
})
|
||||
.size(IconSize::Md)
|
||||
.color(colors.fg_muted),
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::{Tree, TreeNode};
|
||||
use crate::{
|
||||
data_display::Tone,
|
||||
forms::{SearchInput, TextInput},
|
||||
primitives::IconName,
|
||||
primitives::{IconName, file_icon},
|
||||
theme::ControlSize,
|
||||
};
|
||||
|
||||
@@ -57,19 +57,6 @@ impl GitStatus {
|
||||
}
|
||||
}
|
||||
|
||||
/// The icon a file's name suggests.
|
||||
fn icon_for(name: &str) -> IconName {
|
||||
let kind = name.rsplit_once('.').map(|(_, kind)| kind.to_lowercase());
|
||||
match kind.as_deref() {
|
||||
Some("rs" | "ts" | "tsx" | "js" | "py" | "go" | "swift" | "c" | "h" | "sh") => {
|
||||
IconName::Code
|
||||
}
|
||||
Some("png" | "jpg" | "jpeg" | "gif" | "svg" | "webp") => IconName::Image,
|
||||
Some("toml" | "json" | "yaml" | "yml" | "lock") => IconName::SlidersHorizontal,
|
||||
_ => IconName::FileText,
|
||||
}
|
||||
}
|
||||
|
||||
/// A folder of the tree being built: its folders and its files, each by name.
|
||||
#[derive(Default)]
|
||||
struct Folder {
|
||||
@@ -141,7 +128,7 @@ fn nodes(
|
||||
files.sort_by_key(|name| name.to_lowercase());
|
||||
for name in files {
|
||||
let path = format!("{prefix}{name}");
|
||||
let node = TreeNode::new(path.clone(), name.clone()).icon(icon_for(name));
|
||||
let node = TreeNode::new(path.clone(), name.clone()).icon(file_icon(name));
|
||||
out.push(match state(&path) {
|
||||
Some(state) => {
|
||||
changed = true;
|
||||
|
||||
@@ -9,6 +9,7 @@ use gpui::{
|
||||
use super::scrubber::{OnTime, clock, time_at, time_tip};
|
||||
use crate::{
|
||||
forms::keyed,
|
||||
layout::fit,
|
||||
primitives::{FocusRing, tab_stop},
|
||||
theme::{ActiveTheme, Radius},
|
||||
};
|
||||
@@ -32,13 +33,6 @@ pub(crate) fn resample(peaks: &[f32], count: usize) -> Vec<f32> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How many bars `bar` wide with `gap` between fit across `width`, at least one, and the inset that centers them.
|
||||
pub(crate) fn fit(width: f32, bar: f32, gap: f32) -> (usize, f32) {
|
||||
let count = ((width + gap) / (bar + gap)).floor().max(1.0) as usize;
|
||||
let used = (bar + gap) * count as f32 - gap;
|
||||
(count, ((width - used) / 2.0).max(0.0))
|
||||
}
|
||||
|
||||
/// A seeking drag on a waveform, marked with its waveform.
|
||||
struct Seek(EntityId);
|
||||
|
||||
@@ -121,7 +115,8 @@ impl RenderOnce for AudioWaveform {
|
||||
move |bounds, _, window, _| {
|
||||
let width = f32::from(bounds.size.width);
|
||||
let pitch = f32::from(bar + gap);
|
||||
let (count, inset) = fit(width, f32::from(bar), f32::from(gap));
|
||||
let (count, inset) = fit(bounds.size.width, bar, gap);
|
||||
let inset = f32::from(inset);
|
||||
for (ix, peak) in resample(&peaks, count).into_iter().enumerate() {
|
||||
let left = inset + pitch * ix as f32;
|
||||
let tall = (bounds.size.height * peak).max(bar);
|
||||
@@ -248,14 +243,7 @@ impl RenderOnce for AudioSpectrum {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{fit, resample};
|
||||
|
||||
#[test]
|
||||
fn the_bars_that_fit_sit_centered() {
|
||||
assert_eq!(fit(100.0, 3.0, 2.0), (20, 1.0));
|
||||
assert_eq!(fit(98.0, 3.0, 2.0), (20, 0.0));
|
||||
assert_eq!(fit(2.0, 3.0, 2.0), (1, 0.0));
|
||||
}
|
||||
use super::resample;
|
||||
|
||||
#[test]
|
||||
fn peaks_gather_into_bars_by_their_loudest() {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use super::IconName;
|
||||
|
||||
/// The icon a file's name suggests, by its extension.
|
||||
pub(crate) fn file_icon(name: &str) -> IconName {
|
||||
let extension = name.rsplit_once('.').map(|(_, ext)| ext.to_lowercase());
|
||||
match extension.as_deref() {
|
||||
Some(
|
||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "swift" | "c" | "h" | "cpp" | "java"
|
||||
| "kt" | "rb" | "php" | "css" | "html",
|
||||
) => IconName::FileCode,
|
||||
Some("sh" | "zsh" | "bash" | "command") => IconName::FileTerminal,
|
||||
Some("toml" | "yaml" | "yml" | "ini" | "lock" | "env" | "conf") => IconName::FileCog,
|
||||
Some("json") => IconName::FileJson,
|
||||
Some("png" | "jpg" | "jpeg" | "gif" | "webp" | "svg" | "heic" | "bmp" | "tiff") => {
|
||||
IconName::FileImage
|
||||
}
|
||||
Some("mp3" | "wav" | "flac" | "aac" | "m4a" | "ogg" | "aiff") => IconName::FileAudio,
|
||||
Some("mp4" | "mov" | "mkv" | "webm" | "avi") => IconName::FileVideoCamera,
|
||||
Some("zip" | "tar" | "gz" | "tgz" | "rar" | "7z") => IconName::FileArchive,
|
||||
Some("csv" | "xls" | "xlsx" | "numbers") => IconName::FileSpreadsheet,
|
||||
Some("ttf" | "otf" | "woff" | "woff2") => IconName::FileType,
|
||||
Some("md" | "txt" | "rtf" | "pdf" | "doc" | "docx" | "pages") => IconName::FileText,
|
||||
_ => IconName::File,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{IconName, file_icon};
|
||||
|
||||
#[test]
|
||||
fn a_name_picks_its_icon_by_its_last_extension() {
|
||||
assert_eq!(file_icon("main.RS"), IconName::FileCode);
|
||||
assert_eq!(file_icon("lift.PNG"), IconName::FileImage);
|
||||
assert_eq!(file_icon("notes.md"), IconName::FileText);
|
||||
assert_eq!(file_icon("backup.tar.gz"), IconName::FileArchive);
|
||||
assert_eq!(file_icon(".env"), IconName::FileCog);
|
||||
assert_eq!(file_icon("take.mov"), IconName::FileVideoCamera);
|
||||
assert_eq!(file_icon("Makefile"), IconName::File);
|
||||
}
|
||||
}
|
||||
@@ -129,9 +129,19 @@ icons! {
|
||||
ExternalLink => "external-link",
|
||||
EyeOff => "eye-off",
|
||||
Eye => "eye",
|
||||
FileArchive => "file-archive",
|
||||
FileAudio => "file-audio",
|
||||
FileCode => "file-code",
|
||||
FileCog => "file-cog",
|
||||
FileImage => "file-image",
|
||||
FileJson => "file-json",
|
||||
FilePlus => "file-plus",
|
||||
FileSpreadsheet => "file-spreadsheet",
|
||||
FileTerminal => "file-terminal",
|
||||
FileText => "file-text",
|
||||
File => "file",
|
||||
FileType => "file-type",
|
||||
FileVideoCamera => "file-video-camera",
|
||||
Filter => "filter",
|
||||
Fingerprint => "fingerprint",
|
||||
Flag => "flag",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod backdrop;
|
||||
mod disclosure;
|
||||
mod divider;
|
||||
mod files;
|
||||
mod focus;
|
||||
mod ghost;
|
||||
mod icon;
|
||||
@@ -13,6 +14,7 @@ mod tooltip;
|
||||
pub use backdrop::{Backdrop, Place};
|
||||
pub use disclosure::Disclosure;
|
||||
pub use divider::Divider;
|
||||
pub(crate) use files::file_icon;
|
||||
pub use focus::{FocusNext, FocusPrev, FocusRing, FocusScope};
|
||||
pub(crate) use focus::{Takeover, give_back, hand_back, hold_focus, tab_stop, take_focus};
|
||||
pub use ghost::DragGhost;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use gpui::Rems;
|
||||
|
||||
use super::{Theme, tokens::px_to_rems};
|
||||
|
||||
/// File views' measures.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FileSizes {
|
||||
/// A tile in a grid of files, wide.
|
||||
pub tile: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn files(&self) -> FileSizes {
|
||||
FileSizes {
|
||||
tile: px_to_rems(96.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod chart;
|
||||
mod files;
|
||||
mod generative;
|
||||
mod media;
|
||||
mod palette;
|
||||
@@ -10,6 +11,7 @@ use gpui::{App, Global, SharedString, WindowAppearance};
|
||||
|
||||
use crate::motion;
|
||||
pub use chart::ChartSizes;
|
||||
pub use files::FileSizes;
|
||||
pub use generative::GenerativeSizes;
|
||||
pub use media::MediaSizes;
|
||||
pub use palette::{Mix, Palette, Syntax};
|
||||
|
||||
@@ -287,23 +287,25 @@
|
||||
- [x] DeviceSelector
|
||||
- [x] 3DModelViewer — host frames, orbit input
|
||||
|
||||
## T27 Files — `src/files`
|
||||
## T27a Files · Items — `src/files`
|
||||
|
||||
- [x] FileIcon — one map from name to icon, in `primitives`
|
||||
- [x] FileItem
|
||||
- [x] FileList → `lists::DirectoryListing`
|
||||
- [x] FileGrid
|
||||
|
||||
## T27b Files · Explorer
|
||||
|
||||
- [ ] FileExplorer — list, icons, columns
|
||||
- [ ] FileGrid
|
||||
- [ ] FileList
|
||||
- [ ] FileItem
|
||||
- [ ] FileIcon
|
||||
- [ ] FilePreview / QuickLook
|
||||
- [ ] FileBreadcrumb / PathBar → `navigation::Breadcrumb`
|
||||
- [ ] FolderTree → `lists::FileTree`
|
||||
- [ ] FileContextMenu → `menus::ContextMenu`
|
||||
- [ ] RenameInline → `forms::InlineEdit`
|
||||
- [ ] DragDropFiles → `forms::DropZone`
|
||||
- [ ] FileOperationProgress
|
||||
- [ ] TransferQueue
|
||||
- [ ] DownloadManager
|
||||
- [ ] StorageUsage → `data_display::UsageBar`
|
||||
|
||||
## T27c Files · Find and look
|
||||
|
||||
- [ ] FilePreview / QuickLook
|
||||
- [ ] RecentFiles
|
||||
- [ ] Favorites / Bookmarks → `documents::Favorites`
|
||||
- [ ] FileSearch
|
||||
@@ -311,6 +313,13 @@
|
||||
- [ ] ArchiveViewer — host entries
|
||||
- [ ] HexViewer → `debug::HexViewer`
|
||||
|
||||
## T27d Files · Transfers
|
||||
|
||||
- [ ] FileOperationProgress
|
||||
- [ ] TransferQueue
|
||||
- [ ] DownloadManager
|
||||
- [ ] StorageUsage → `data_display::UsageBar`
|
||||
|
||||
## T28 Messaging — `src/messaging`
|
||||
|
||||
- [ ] ChannelList / ChatList
|
||||
|
||||