diff --git a/TASKS.md b/TASKS.md index b5ce823..a740555 100644 --- a/TASKS.md +++ b/TASKS.md @@ -190,3 +190,4 @@ Per-component lines live in `tasks/`. Tags there: | T12d | 1 | — | pending: codex still logged out | | T13a | 1 | — | pending: codex still logged out | | T13b | 1 | — | pending: codex still logged out | +| T13c | 1 | — | pending: codex still logged out | diff --git a/examples/gallery/pages/lists/files.rs b/examples/gallery/pages/lists/files.rs new file mode 100644 index 0000000..477344f --- /dev/null +++ b/examples/gallery/pages/lists/files.rs @@ -0,0 +1,115 @@ +use ely_gpui_component::{ + lists::{DirEntry, DirectoryListing, FileTree, GitStatus}, + theme::{ActiveTheme, Radius}, + typography::Caption, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; +use jiff::{Timestamp, ToSpan}; + +use crate::{ + probe::probe, + ui::{keep, section, set}, +}; + +const PROJECT: [&str; 12] = [ + "Cargo.toml", + "README.md", + "LICENSE", + "src/main.rs", + "src/app.rs", + "src/ui/sidebar.rs", + "src/ui/editor.rs", + "src/ui/toolbar.rs", + "src/sync/cloud.rs", + "assets/logo.svg", + "assets/fonts/Inter.ttf", + "tests/editing.rs", +]; + +pub fn file_tree(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let opened = keep("file-opened", || None::, window, cx); + let (now, store) = (opened.read(cx).clone(), opened.clone()); + let theme = cx.theme(); + section( + "FileTree", + "A project's files: folders first, an icon for each kind, git status by letter and tint, and a dot on folders with changes. Type to filter; matches keep their folders, which open.", + cx, + ) + .child(probe( + "file-tree", + div() + .w(px(320.)) + .h(px(360.)) + .p_2() + .rounded(theme.radius(Radius::Md)) + .border_1() + .border_color(theme.colors.border) + .child( + FileTree::new("files", PROJECT) + .status("src/ui/sidebar.rs", GitStatus::Modified) + .status("src/sync/cloud.rs", GitStatus::Added) + .status("tests/editing.rs", GitStatus::Untracked) + .status("LICENSE", GitStatus::Deleted) + .on_open(move |path, _, cx| set(&store, Some(path.clone()), cx)) + .size_full(), + ), + )) + .child(Caption::new(match now { + Some(path) => format!("Opened {path}"), + None => "Double press a file, or press Enter, to open it.".to_string(), + })) +} + +/// The demo disk: each folder's path and what it holds. +fn listing(path: &[&str], now: Timestamp) -> Vec { + let ago = |hours: i64| now.checked_sub(hours.hours()).expect("a time in range"); + match path.last().copied() { + Some("Projects") => vec![ + DirEntry::folder("Ely", ago(2)), + DirEntry::file("Roadmap.key", 18_400_000, ago(30)), + DirEntry::file("Sketches.fig", 6_200_000, ago(90)), + ], + _ => vec![ + DirEntry::folder("Projects", ago(3)), + DirEntry::folder("Receipts", ago(240)), + DirEntry::file("Budget 2026.numbers", 2_310_000, ago(26)), + DirEntry::file("Letter.pages", 410_000, ago(5)), + DirEntry::file("Scan 12.pdf", 1_120_000, ago(120)), + DirEntry::file("notes.txt", 3_200, ago(1)), + ], + } +} + +pub fn directory(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let opened = keep("dir-opened", Timestamp::now, window, cx); + let at = keep( + "dir-path", + || vec!["Macintosh HD", "Users", "grace", "Documents"], + window, + cx, + ); + let (path, into, back) = (at.read(cx).clone(), at.clone(), at.clone()); + let entries = listing(&path, *opened.read(cx)); + section( + "DirectoryListing", + "A folder's contents in columns. A header sorts by its column and a second press reverses it; folders stay first. Open a folder with a double press or Enter; the path above climbs back.", + cx, + ) + .child( + div().w(px(560.)).child( + DirectoryListing::new("directory", path.clone(), entries) + .on_open(move |entry, _, cx| { + if entry.is_folder() && entry.name().as_ref() == "Projects" { + let mut next = into.read(cx).clone(); + next.push("Projects"); + set(&into, next, cx); + } + }) + .on_climb(move |level, _, cx| { + let mut next = back.read(cx).clone(); + next.truncate(level + 1); + set(&back, next, cx); + }), + ), + ) +} diff --git a/examples/gallery/pages/lists/mod.rs b/examples/gallery/pages/lists/mod.rs index 6d26ec0..20d0eb4 100644 --- a/examples/gallery/pages/lists/mod.rs +++ b/examples/gallery/pages/lists/mod.rs @@ -1,3 +1,4 @@ +mod files; mod long; mod rows; mod trees; @@ -42,6 +43,12 @@ const SCRIPT: &[Step] = &[ Step::UpAt("outline", 40.0, 150.0), Step::Wait(500), Step::Shot("outline-moved"), + Step::DownAt("file-tree", 120.0, 22.0), + Step::UpAt("file-tree", 120.0, 22.0), + Step::Key("cmd-a"), + Step::Type("side"), + Step::Wait(300), + Step::Shot("files-filtered"), Step::Rest, ]; @@ -58,5 +65,7 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(trees::checkbox(window, cx)) .child(trees::select(window, cx)) .child(trees::outline(window, cx)) + .child(files::file_tree(window, cx)) + .child(files::directory(window, cx)) .into_any_element() } diff --git a/src/lists/directory.rs b/src/lists/directory.rs new file mode 100644 index 0000000..723395a --- /dev/null +++ b/src/lists/directory.rs @@ -0,0 +1,318 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*, +}; +use jiff::Timestamp; + +use super::{ListItem, SelectableList}; +use crate::{ + navigation::{Breadcrumb, Crumb}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, TextSize}, + typography::{format, tabular}, +}; + +/// One entry of a directory: a folder, or a file with its size; both with when they last changed. +#[derive(Clone, Debug, PartialEq)] +pub struct DirEntry { + name: SharedString, + size: Option, + modified: Timestamp, +} + +impl DirEntry { + pub fn folder(name: impl Into, modified: Timestamp) -> Self { + Self { + name: name.into(), + size: None, + modified, + } + } + + pub fn file(name: impl Into, size: u64, modified: Timestamp) -> Self { + Self { + name: name.into(), + size: Some(size), + modified, + } + } + + pub fn name(&self) -> &SharedString { + &self.name + } + + pub fn is_folder(&self) -> bool { + self.size.is_none() + } +} + +/// A column a listing sorts by. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Column { + Name, + Size, + Modified, +} + +/// Entries sorted by `column`, folders always first, ties by name. +fn sorted(mut entries: Vec, column: Column, ascending: bool) -> Vec { + let by_name = |a: &DirEntry, b: &DirEntry| a.name.to_lowercase().cmp(&b.name.to_lowercase()); + entries.sort_by(|a, b| { + let chosen = match column { + Column::Name => by_name(a, b), + Column::Size => a.size.cmp(&b.size), + Column::Modified => a.modified.cmp(&b.modified), + }; + let chosen = if ascending { chosen } else { chosen.reverse() }; + b.is_folder() + .cmp(&a.is_folder()) + .then(chosen) + .then_with(|| by_name(a, b)) + }); + entries +} + +type OnEntry = Rc; +type OnClimb = Rc; + +/// A directory's entries in columns: name, size, and when each changed. A header sorts by its column and a second press reverses it; folders stay first. Enter or a double press opens an entry; the path above climbs to an earlier level. +#[derive(IntoElement)] +pub struct DirectoryListing { + id: ElementId, + path: Vec, + entries: Vec, + on_open: Option, + on_climb: Option, +} + +impl DirectoryListing { + /// `path` names each level from the root down to this directory. + pub fn new( + id: impl Into, + path: impl IntoIterator>, + entries: impl IntoIterator, + ) -> Self { + let path: Vec = path.into_iter().map(Into::into).collect(); + assert!(!path.is_empty(), "a listing needs the directory it lists"); + Self { + id: id.into(), + path, + entries: entries.into_iter().collect(), + on_open: None, + on_climb: None, + } + } + + pub fn on_open(mut self, handler: impl Fn(&DirEntry, &mut Window, &mut App) + 'static) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } + + /// Gets the level pressed in the path, zero at the root. + pub fn on_climb(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_climb = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for DirectoryListing { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let order = + window.use_keyed_state((self.id.clone(), "order"), cx, |_, _| (Column::Name, true)); + let picked = window.use_keyed_state((self.id.clone(), "picked"), cx, |_, _| { + Vec::::new() + }); + let (column, ascending) = *order.read(cx); + let entries = Rc::new(sorted(self.entries, column, ascending)); + let theme = cx.theme(); + let colors = &theme.colors; + let (size_width, date_width) = (theme.label_width() * 0.5, theme.label_width() * 0.75); + let now = Timestamp::now(); + let header = |label: &'static str, this: Column| { + let order = order.clone(); + div() + .id((self.id.clone(), label)) + .flex() + .items_center() + .gap_1() + .cursor_pointer() + .hover(|style| style.text_color(colors.fg)) + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, _, cx| { + order.update(cx, |order, cx| { + *order = if order.0 == this { + (this, !order.1) + } else { + (this, true) + }; + log::info!( + "directory listing: sorted by {:?}, {}", + order.0, + if order.1 { "rising" } else { "falling" } + ); + cx.notify(); + }) + }) + .child(label) + .when(column == this, |header| { + header.child( + Icon::new(if ascending { + IconName::ArrowUpNarrowWide + } else { + IconName::ArrowDownWideNarrow + }) + .size(IconSize::Xs) + .color(colors.fg_muted), + ) + }) + }; + let heading = div() + .flex() + .items_center() + .gap_3() + .px_3() + .py_1p5() + .border_b_1() + .border_color(colors.border) + .text_size(theme.text_size(TextSize::Xs)) + .text_color(colors.fg_muted) + .child(div().flex_1().child(header("Name", Column::Name))) + .child( + div() + .w(size_width) + .flex() + .justify_end() + .child(header("Size", Column::Size)), + ) + .child( + div() + .w(date_width) + .flex() + .justify_end() + .child(header("Modified", Column::Modified)), + ); + let list = entries.iter().enumerate().fold( + SelectableList::new((self.id.clone(), "entries")).selected(picked.read(cx).clone()), + |list, (ix, entry)| { + let size = entry + .size + .map(|bytes| format::file_size(bytes, false)) + .unwrap_or_else(|| "—".into()); + let cells = div() + .flex() + .gap_3() + .child( + tabular(div()) + .w(size_width) + .flex() + .justify_end() + .child(size), + ) + .child( + div() + .w(date_width) + .flex() + .justify_end() + .child(format::relative(entry.modified, now)), + ); + let item = + ListItem::new((self.id.clone(), format!("entry-{ix}")), entry.name.clone()) + .leading( + Icon::new(if entry.is_folder() { + IconName::Folder + } else { + IconName::File + }) + .size(IconSize::Md) + .color(colors.fg_muted), + ) + .trailing(cells); + list.row(entry.name.clone(), item) + }, + ); + let (store, shown, on_open) = (picked.clone(), entries.clone(), self.on_open); + let crumbs = self.path.iter().enumerate().map(|(ix, name)| { + let crumb = Crumb::new(SharedString::from(ix.to_string()), name.clone()); + if ix == 0 { + crumb.icon(IconName::HardDrive) + } else { + crumb + } + }); + let on_climb = self.on_climb; + div() + .flex() + .flex_col() + .gap_2() + .child( + Breadcrumb::new((self.id.clone(), "path"), crumbs).on_select( + move |level, _, window, cx| { + if let Some(on_climb) = &on_climb { + on_climb(level, window, cx); + } + }, + ), + ) + .child(heading) + .child( + list.on_change(move |keys, _, cx| { + store.update(cx, |picked, cx| { + *picked = keys.to_vec(); + cx.notify(); + }) + }) + .on_activate(move |name, window, cx| { + let entry = shown + .iter() + .find(|entry| entry.name == *name) + .expect("a listed entry"); + if let Some(on_open) = &on_open { + on_open(entry, window, cx); + } + }), + ) + } +} + +#[cfg(test)] +mod tests { + use jiff::Timestamp; + + use super::{Column, DirEntry, sorted}; + + fn names(entries: Vec) -> Vec { + entries + .into_iter() + .map(|entry| entry.name.to_string()) + .collect() + } + + #[test] + fn folders_stay_first_whichever_way_it_sorts() { + let at = |seconds: i64| Timestamp::from_second(seconds).expect("a time"); + let entries = vec![ + DirEntry::file("notes.md", 900, at(30)), + DirEntry::folder("Photos", at(10)), + DirEntry::file("archive.zip", 50_000, at(20)), + DirEntry::folder("apps", at(40)), + ]; + assert_eq!( + names(sorted(entries.clone(), Column::Name, true)), + ["apps", "Photos", "archive.zip", "notes.md"] + ); + assert_eq!( + names(sorted(entries.clone(), Column::Size, false)), + ["apps", "Photos", "archive.zip", "notes.md"] + ); + assert_eq!( + names(sorted(entries.clone(), Column::Modified, true)), + ["Photos", "apps", "archive.zip", "notes.md"] + ); + assert_eq!( + names(sorted(entries, Column::Name, false)), + ["Photos", "apps", "notes.md", "archive.zip"] + ); + } +} diff --git a/src/lists/files.rs b/src/lists/files.rs new file mode 100644 index 0000000..7e48df3 --- /dev/null +++ b/src/lists/files.rs @@ -0,0 +1,308 @@ +use std::{collections::BTreeMap, rc::Rc}; + +use gpui::{ + App, Div, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, StyleRefinement, + Styled, Window, div, +}; + +use super::{Tree, TreeNode}; +use crate::{ + data_display::Tone, + forms::{SearchInput, TextInput}, + primitives::IconName, + theme::ControlSize, +}; + +/// A file's state in git. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GitStatus { + Modified, + Added, + Deleted, + Untracked, + Renamed, + Conflicted, +} + +impl GitStatus { + fn letter(self) -> &'static str { + match self { + Self::Modified => "M", + Self::Added => "A", + Self::Deleted => "D", + Self::Untracked => "U", + Self::Renamed => "R", + Self::Conflicted => "!", + } + } + + fn tone(self) -> Tone { + match self { + Self::Modified => Tone::Warning, + Self::Added | Self::Untracked => Tone::Success, + Self::Deleted | Self::Conflicted => Tone::Danger, + Self::Renamed => Tone::Info, + } + } +} + +/// 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 { + folders: BTreeMap, + files: Vec, +} + +/// Whether `name` matches `query`, ignoring case; an empty query matches all. +fn matches(name: &str, query: &str) -> bool { + query.is_empty() || name.to_lowercase().contains(&query.to_lowercase()) +} + +/// Paths as nodes, folders first, then files, each by name; with a query, only matches and the folders that hold them, which it opens. +fn nodes( + paths: &[SharedString], + status: &[(SharedString, GitStatus)], + query: &str, +) -> (Vec, Vec) { + let mut root = Folder::default(); + for path in paths { + let mut parts: Vec<&str> = path.split('/').collect(); + let file = parts.pop().expect("a path has a name").to_string(); + let folder = parts.iter().fold(&mut root, |folder, part| { + folder.folders.entry(part.to_string()).or_default() + }); + folder.files.push(file); + } + let state = |path: &str| { + status + .iter() + .find(|(known, _)| known.as_ref() == path) + .map(|(_, state)| *state) + }; + fn walk( + folder: &Folder, + prefix: &str, + query: &str, + state: &dyn Fn(&str) -> Option, + open: &mut Vec, + ) -> (Vec, bool) { + let mut out = Vec::new(); + let mut changed = false; + let mut folders: Vec<_> = folder.folders.iter().collect(); + folders.sort_by_key(|(name, _)| name.to_lowercase()); + for (name, inner) in folders { + let path = format!("{prefix}{name}"); + let (children, inner_changed) = walk(inner, &format!("{path}/"), query, state, open); + if children.is_empty() && !matches(name, query) { + continue; + } + if !query.is_empty() { + open.push(path.clone().into()); + } + changed |= inner_changed; + let node = TreeNode::new(path, name.clone()) + .icon(IconName::Folder) + .children(children); + out.push(if inner_changed { + node.note("•").tone(Tone::Warning) + } else { + node + }); + } + let mut files: Vec<_> = folder + .files + .iter() + .filter(|name| matches(name, query)) + .collect(); + 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)); + out.push(match state(&path) { + Some(state) => { + changed = true; + node.note(state.letter()).tone(state.tone()) + } + None => node, + }); + } + (out, changed) + } + let mut open = Vec::new(); + let (nodes, _) = walk(&root, "", query, &state, &mut open); + (nodes, open) +} + +type OnOpen = Rc; + +/// A project's files as a tree: folders first, an icon for each kind, git status by letter and tint, a dot on folders with changes, and a filter that keeps matches and opens the folders that hold them. Enter or a double press opens a file. Give it a height. +#[derive(IntoElement)] +pub struct FileTree { + id: ElementId, + base: Div, + paths: Vec, + status: Vec<(SharedString, GitStatus)>, + on_open: Option, +} + +impl FileTree { + /// Files by their path from the project's root, with `/` between folders. + pub fn new( + id: impl Into, + paths: impl IntoIterator>, + ) -> Self { + Self { + id: id.into(), + base: div(), + paths: paths.into_iter().map(Into::into).collect(), + status: Vec::new(), + on_open: None, + } + } + + pub fn status(mut self, path: impl Into, status: GitStatus) -> Self { + self.status.push((path.into(), status)); + self + } + + pub fn on_open( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_open = Some(Rc::new(handler)); + self + } +} + +impl Styled for FileTree { + fn style(&mut self) -> &mut StyleRefinement { + self.base.style() + } +} + +impl RenderOnce for FileTree { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let filter = window.use_keyed_state((self.id.clone(), "filter"), cx, |window, cx| { + TextInput::new(window, cx) + }); + let picked = window.use_keyed_state((self.id.clone(), "picked"), cx, |_, _| { + Vec::::new() + }); + let query = filter.read(cx).text().trim().to_string(); + let (nodes, open) = nodes(&self.paths, &self.status, &query); + let top: Vec = nodes + .iter() + .filter(|node| node.opens()) + .map(|node| node.key.clone()) + .collect(); + let (id, on_open, store) = (self.id.clone(), self.on_open, picked.clone()); + let tree = Tree::new((self.id.clone(), "tree"), nodes) + .open(if query.is_empty() { top } else { open }) + .selected(picked.read(cx).clone()) + .on_select(move |keys, _, cx| { + store.update(cx, |picked, cx| { + *picked = keys.to_vec(); + cx.notify(); + }) + }) + .on_activate(move |path, window, cx| { + log::info!("file tree {id:?}: opened {path}"); + if let Some(on_open) = &on_open { + on_open(path, window, cx); + } + }) + .flex_1() + .min_h_0(); + self.base + .flex() + .flex_col() + .gap_2() + .child(SearchInput::new((self.id, "search"), &filter).size(ControlSize::Sm)) + .child(tree) + } +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use super::{GitStatus, nodes}; + use crate::lists::tree::{Shown, rows}; + + fn paths() -> Vec { + [ + "src/main.rs", + "README.md", + "src/ui/Button.rs", + "assets/logo.svg", + "src/app.rs", + "Cargo.toml", + ] + .map(SharedString::from) + .to_vec() + } + + /// Labels in row order with everything open, each with its note. + fn shown(query: &str) -> (Vec, Vec) { + let status = [ + ("src/ui/Button.rs".into(), GitStatus::Modified), + ("README.md".into(), GitStatus::Added), + ]; + let (nodes, open) = nodes(&paths(), &status, query); + let every = ["assets", "src", "src/ui"] + .map(SharedString::from) + .into_iter() + .collect(); + let labels = rows(&nodes, &every) + .into_iter() + .filter_map(|row| match row.shown { + Shown::Node { label, note, .. } => Some(format!( + "{label}{}", + note.map(|note| format!(" {note}")).unwrap_or_default() + )), + Shown::Loading => None, + }) + .collect(); + (labels, open) + } + + #[test] + fn folders_come_first_then_files_by_name_with_their_status() { + let (labels, open) = shown(""); + assert_eq!( + labels, + [ + "assets", + "logo.svg", + "src •", + "ui •", + "Button.rs M", + "app.rs", + "main.rs", + "Cargo.toml", + "README.md A" + ] + ); + assert!(open.is_empty()); + } + + #[test] + fn a_filter_keeps_matches_and_opens_their_folders() { + let (labels, open) = shown("butt"); + assert_eq!(labels, ["src •", "ui •", "Button.rs M"]); + assert_eq!(open, ["src/ui", "src"].map(SharedString::from)); + } +} diff --git a/src/lists/mod.rs b/src/lists/mod.rs index eeeede8..8c18e2f 100644 --- a/src/lists/mod.rs +++ b/src/lists/mod.rs @@ -1,3 +1,5 @@ +mod directory; +mod files; mod grouped; mod item; mod long; @@ -9,6 +11,8 @@ mod swipe; mod tests; mod tree; +pub use directory::{DirEntry, DirectoryListing}; +pub use files::{FileTree, GitStatus}; pub use grouped::GroupedList; pub use item::{List, ListItem}; pub use long::{InfiniteList, VirtualList}; diff --git a/src/lists/select.rs b/src/lists/select.rs index 5b8b4d5..400ec31 100644 --- a/src/lists/select.rs +++ b/src/lists/select.rs @@ -47,6 +47,7 @@ struct Cursor { } type OnSelect = Rc; +type OnActivate = Rc; type Picker = Rc; /// Rows you select. A press picks one; with `multiple`, Cmd-press adds or drops one and Shift-press takes the range from the last pick. Up and Down move, Shift with them extends, Space toggles, Cmd-A takes all. @@ -58,6 +59,7 @@ pub struct SelectableList { selected: Vec, multiple: bool, on_change: Option, + on_activate: Option, } impl SelectableList { @@ -69,6 +71,7 @@ impl SelectableList { selected: Vec::new(), multiple: false, on_change: None, + on_activate: None, } } @@ -88,6 +91,15 @@ impl SelectableList { self } + /// Enter, or a double press, on a row. + pub fn on_activate( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_activate = Some(Rc::new(handler)); + self + } + /// Gets the keys now selected, in row order. pub fn on_change( mut self, @@ -150,12 +162,21 @@ impl RenderOnce for SelectableList { } }) }; + let activate: OnActivate = { + let (id, on_activate) = (self.id.clone(), self.on_activate); + Rc::new(move |key, window, cx| { + log::info!("selectable list {id:?}: activated {key}"); + if let Some(on_activate) = &on_activate { + on_activate(key, window, cx); + } + }) + }; let rows: Vec<_> = self .rows .into_iter() .enumerate() .map(|(ix, (key, item))| { - let (pick, focus) = (pick.clone(), focus.clone()); + let (pick, focus, activate) = (pick.clone(), focus.clone(), activate.clone()); item.selected(selected.contains(&key)) .current(focused && ix == at) .on_click(move |event, window, cx| { @@ -167,6 +188,9 @@ impl RenderOnce for SelectableList { }; window.focus(&focus); pick(ix, how, window, cx); + if event.click_count() == 2 { + activate(&key, window, cx); + } }) }) .collect(); @@ -191,6 +215,11 @@ impl RenderOnce for SelectableList { "home" => (0, extend), "end" => (count - 1, extend), "space" => (at, Pick::Toggle), + "enter" => { + cx.stop_propagation(); + activate(&keys[at], window, cx); + return; + } "a" if held.platform => (at, Pick::All), _ => return, }; diff --git a/src/lists/tests.rs b/src/lists/tests.rs index f29c3e7..1f46545 100644 --- a/src/lists/tests.rs +++ b/src/lists/tests.rs @@ -36,12 +36,12 @@ fn settle(cx: &mut VisualTestContext) { const NAMES: [&str; 4] = ["a", "b", "c", "d"]; -/// A multiple-choice list of four rows that keeps what it reports. -struct Picks(Vec); +/// A multiple-choice list of four rows that keeps what it reports, and what was opened. +struct Picks(Vec, Vec); impl Render for Picks { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let view = cx.entity(); + let (view, opened) = (cx.entity(), cx.entity()); NAMES .iter() .fold(SelectableList::new("picks").multiple(), |list, name| { @@ -49,6 +49,7 @@ impl Render for Picks { }) .selected(self.0.clone()) .on_change(move |keys, _, cx| view.update(cx, |picks, _| picks.0 = keys.to_vec())) + .on_activate(move |key, _, cx| opened.update(cx, |picks, _| picks.1.push(key.clone()))) .w(px(240.0)) .h(px(200.0)) } @@ -68,7 +69,7 @@ fn row(ix: usize) -> Point { #[gpui::test] fn presses_pick_one_toggle_with_cmd_and_range_with_shift(cx: &mut TestAppContext) { setup(cx); - let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new())); + let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new())); settle(cx); cx.simulate_click(row(0), Modifiers::none()); settle(cx); @@ -272,3 +273,34 @@ fn a_long_list_builds_only_what_is_near_the_view(cx: &mut TestAppContext) { let most = built.borrow().iter().copied().max().expect("some rows"); assert!((9..60).contains(&most), "built up to row {most} of 1000"); } + +#[gpui::test] +fn enter_or_a_double_press_opens_a_row(cx: &mut TestAppContext) { + setup(cx); + let (view, cx) = cx.add_window_view(|_, _| Picks(Vec::new(), Vec::new())); + settle(cx); + cx.simulate_click(row(1), Modifiers::none()); + settle(cx); + cx.simulate_keystrokes("enter"); + settle(cx); + for count in [1, 2] { + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: row(3), + modifiers: Modifiers::none(), + click_count: count, + first_mouse: false, + }); + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: row(3), + modifiers: Modifiers::none(), + click_count: count, + }); + } + settle(cx); + let opened: Vec = view.read_with(cx, |picks, _| { + picks.1.iter().map(|key| key.to_string()).collect() + }); + assert_eq!(opened, ["b", "d"]); +} diff --git a/src/lists/tree/model.rs b/src/lists/tree/model.rs index 0891563..a930466 100644 --- a/src/lists/tree/model.rs +++ b/src/lists/tree/model.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use gpui::SharedString; -use crate::{forms::CheckState, primitives::IconName}; +use crate::{data_display::Tone, forms::CheckState, primitives::IconName}; /// What a node holds under it. #[derive(Clone, Debug, PartialEq)] @@ -19,6 +19,8 @@ pub struct TreeNode { pub(crate) key: SharedString, pub(crate) label: SharedString, pub(crate) icon: Option, + pub(crate) note: Option, + pub(crate) tone: Option, pub(crate) children: Children, } @@ -29,10 +31,24 @@ impl TreeNode { key: key.into(), label: label.into(), icon: None, + note: None, + tone: None, children: Children::Leaf, } } + /// Quiet text at the row's end, such as a status letter. + pub fn note(mut self, note: impl Into) -> Self { + self.note = Some(note.into()); + self + } + + /// Tints the label and the note. + pub fn tone(mut self, tone: impl Into) -> Self { + self.tone = Some(tone.into()); + self + } + pub fn icon(mut self, icon: IconName) -> Self { self.icon = Some(icon); self @@ -89,6 +105,8 @@ pub(crate) enum Shown { key: SharedString, label: SharedString, icon: Option, + note: Option, + tone: Option, opens: bool, open: bool, }, @@ -129,6 +147,8 @@ pub(crate) fn rows(nodes: &[TreeNode], open: &HashSet) -> Vec key: node.key.clone(), label: node.label.clone(), icon: node.icon, + note: node.note.clone(), + tone: node.tone, opens: node.opens(), open: is_open, }, diff --git a/src/lists/tree/row.rs b/src/lists/tree/row.rs index 154478d..9eb97bc 100644 --- a/src/lists/tree/row.rs +++ b/src/lists/tree/row.rs @@ -79,6 +79,7 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement icon, opens, open, + .. } => (key.clone(), label.clone(), *icon, *opens, *open), Shown::Loading => return waiting(rows, ix, item.depth, cx), }; @@ -100,6 +101,10 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement let theme = cx.theme(); let colors = &theme.colors; let indent = theme.tree_indent(); + let (note, ink) = match &item.shown { + Shown::Node { note, tone, .. } => (note.clone(), tone.map(|tone| tone.colors(colors).1)), + Shown::Loading => (None, None), + }; let selected = rows.selected.contains(&key); let (toggle, pick, activate, focus) = ( rows.toggle.clone(), @@ -147,6 +152,7 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement .flex_1() .min_w_0() .truncate() + .when_some(ink, |text, ink| text.text_color(ink)) .child(label.clone()) .into_any_element(), }; @@ -202,6 +208,13 @@ fn row(rows: &Rows, ix: usize, window: &mut Window, cx: &mut App) -> AnyElement ) .children(icon.map(|icon| Icon::new(icon).size(IconSize::Sm).color(colors.fg_muted))) .child(body) + .children(note.map(|note| { + div() + .flex_none() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(ink.unwrap_or(colors.fg_subtle)) + .child(note) + })) .children(landing.filter(|at| *at != DropAt::Inside).map(line)) .on_click(move |event, window, cx| { window.focus(&focus); diff --git a/tasks/ch11-20.md b/tasks/ch11-20.md index a0f4332..01b5152 100644 --- a/tasks/ch11-20.md +++ b/tasks/ch11-20.md @@ -111,10 +111,10 @@ ## T13c Lists & Trees · Files -- [ ] FileTree -- [ ] DirectoryListing -- [ ] Menu List → `menus::Menu` -- [ ] Transfer List → `forms::TransferList` +- [x] FileTree → `lists::FileTree`, `GitStatus`; built on `lists::Tree` +- [x] DirectoryListing → `lists::DirectoryListing`, `DirEntry`; rows through `lists::SelectableList` +- [x] Menu List → `menus::Menu` +- [x] Transfer List → `forms::TransferList` ## T14 Tables — `src/tables`