tree: Add Tree. (#1373)
Close #318 https://github.com/user-attachments/assets/1fed12e2-df53-4213-a3f5-c57bd8b4cadb
This commit is contained in:
parent
4fef96e407
commit
1adaf3ec55
20 changed files with 848 additions and 55 deletions
41
Cargo.lock
generated
41
Cargo.lock
generated
|
|
@ -2657,6 +2657,19 @@ dependencies = [
|
||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "git2"
|
||||||
|
version = "0.20.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.9.1",
|
||||||
|
"libc",
|
||||||
|
"libgit2-sys",
|
||||||
|
"log",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glib"
|
name = "glib"
|
||||||
version = "0.18.5"
|
version = "0.18.5"
|
||||||
|
|
@ -2809,6 +2822,7 @@ dependencies = [
|
||||||
"as-raw-xcb-connection",
|
"as-raw-xcb-connection",
|
||||||
"ashpd 0.11.0",
|
"ashpd 0.11.0",
|
||||||
"async-task",
|
"async-task",
|
||||||
|
"backtrace",
|
||||||
"bindgen 0.71.1",
|
"bindgen 0.71.1",
|
||||||
"blade-graphics",
|
"blade-graphics",
|
||||||
"blade-macros",
|
"blade-macros",
|
||||||
|
|
@ -3928,6 +3942,18 @@ dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libgit2-sys"
|
||||||
|
version = "0.18.2+1.9.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"libz-sys",
|
||||||
|
"pkg-config",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libloading"
|
name = "libloading"
|
||||||
version = "0.8.8"
|
version = "0.8.8"
|
||||||
|
|
@ -3955,6 +3981,18 @@ dependencies = [
|
||||||
"redox_syscall 0.5.17",
|
"redox_syscall 0.5.17",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libz-sys"
|
||||||
|
version = "1.1.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
|
|
@ -9823,11 +9861,13 @@ dependencies = [
|
||||||
"dunce",
|
"dunce",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-lite 1.13.0",
|
"futures-lite 1.13.0",
|
||||||
|
"git2",
|
||||||
"globset",
|
"globset",
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"nix 0.29.0",
|
"nix 0.29.0",
|
||||||
|
"rand 0.9.2",
|
||||||
"regex",
|
"regex",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"schemars",
|
"schemars",
|
||||||
|
|
@ -9844,6 +9884,7 @@ dependencies = [
|
||||||
"which",
|
"which",
|
||||||
"workspace-hack",
|
"workspace-hack",
|
||||||
"zed-collections",
|
"zed-collections",
|
||||||
|
"zed-util-macros",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
14
assets/icons/file.svg
Normal file
14
assets/icons/file.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg
|
||||||
|
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"
|
||||||
|
class="lucide lucide-file-icon lucide-file"
|
||||||
|
><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path
|
||||||
|
d="M14 2v4a2 2 0 0 0 2 2h4"
|
||||||
|
/></svg>
|
||||||
|
After Width: | Height: | Size: 392 B |
|
|
@ -124,16 +124,14 @@ impl ListDelegate for ListItemDeletegate {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<List<Self>>) {
|
fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<List<Self>>) {
|
||||||
_ = self.story.update(cx, |this, cx| {
|
_ = self.story.update(cx, |this, _| {
|
||||||
self.confirmed_index = self.selected_index;
|
self.confirmed_index = self.selected_index;
|
||||||
if let Some(ix) = self.confirmed_index {
|
if let Some(ix) = self.confirmed_index {
|
||||||
if let Some(item) = self.matches.get(ix) {
|
if let Some(item) = self.matches.get(ix) {
|
||||||
this.selected_value = Some(SharedString::from(item.to_string()));
|
this.selected_value = Some(SharedString::from(item.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.close_drawer(cx);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ mod themes;
|
||||||
mod title_bar;
|
mod title_bar;
|
||||||
mod toggle_story;
|
mod toggle_story;
|
||||||
mod tooltip_story;
|
mod tooltip_story;
|
||||||
|
mod tree_story;
|
||||||
mod virtual_list_story;
|
mod virtual_list_story;
|
||||||
mod webview_story;
|
mod webview_story;
|
||||||
mod welcome_story;
|
mod welcome_story;
|
||||||
|
|
@ -103,7 +104,7 @@ pub use textarea_story::TextareaStory;
|
||||||
pub use title_bar::AppTitleBar;
|
pub use title_bar::AppTitleBar;
|
||||||
pub use toggle_story::ToggleStory;
|
pub use toggle_story::ToggleStory;
|
||||||
pub use tooltip_story::TooltipStory;
|
pub use tooltip_story::TooltipStory;
|
||||||
use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _};
|
pub use tree_story::TreeStory;
|
||||||
pub use virtual_list_story::VirtualListStory;
|
pub use virtual_list_story::VirtualListStory;
|
||||||
pub use webview_story::WebViewStory;
|
pub use webview_story::WebViewStory;
|
||||||
pub use welcome_story::WelcomeStory;
|
pub use welcome_story::WelcomeStory;
|
||||||
|
|
@ -120,6 +121,7 @@ use gpui_component::{
|
||||||
scroll::ScrollbarShow,
|
scroll::ScrollbarShow,
|
||||||
v_flex,
|
v_flex,
|
||||||
};
|
};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt as _, util::SubscriberInitExt as _};
|
||||||
|
|
||||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[action(namespace = story, no_json)]
|
#[action(namespace = story, no_json)]
|
||||||
|
|
@ -300,6 +302,7 @@ pub fn init(cx: &mut App) {
|
||||||
webview_story::init(cx);
|
webview_story::init(cx);
|
||||||
tooltip_story::init(cx);
|
tooltip_story::init(cx);
|
||||||
otp_input_story::init(cx);
|
otp_input_story::init(cx);
|
||||||
|
tree_story::init(cx);
|
||||||
|
|
||||||
let http_client = std::sync::Arc::new(
|
let http_client = std::sync::Arc::new(
|
||||||
reqwest_client::ReqwestClient::user_agent("gpui-component/story").unwrap(),
|
reqwest_client::ReqwestClient::user_agent("gpui-component/story").unwrap(),
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ impl Gallery {
|
||||||
StoryContainer::panel::<SwitchStory>(window, cx),
|
StoryContainer::panel::<SwitchStory>(window, cx),
|
||||||
StoryContainer::panel::<TableStory>(window, cx),
|
StoryContainer::panel::<TableStory>(window, cx),
|
||||||
StoryContainer::panel::<TabsStory>(window, cx),
|
StoryContainer::panel::<TabsStory>(window, cx),
|
||||||
|
StoryContainer::panel::<TreeStory>(window, cx),
|
||||||
StoryContainer::panel::<TagStory>(window, cx),
|
StoryContainer::panel::<TagStory>(window, cx),
|
||||||
StoryContainer::panel::<TextareaStory>(window, cx),
|
StoryContainer::panel::<TextareaStory>(window, cx),
|
||||||
StoryContainer::panel::<TooltipStory>(window, cx),
|
StoryContainer::panel::<TooltipStory>(window, cx),
|
||||||
|
|
|
||||||
201
crates/story/src/tree_story.rs
Normal file
201
crates/story/src/tree_story.rs
Normal file
|
|
@ -0,0 +1,201 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use autocorrect::ignorer::Ignorer;
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Context, Entity, InteractiveElement, KeyBinding, ParentElement, Render,
|
||||||
|
Styled, Window, actions, px,
|
||||||
|
};
|
||||||
|
|
||||||
|
use gpui_component::{
|
||||||
|
ActiveTheme as _, IconName, ListItem, StyledExt as _, TreeItem, TreeState, dock::PanelControl,
|
||||||
|
h_flex, label::Label, tree, v_flex,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{Story, section};
|
||||||
|
|
||||||
|
actions!(story, [Rename, SelectItem]);
|
||||||
|
|
||||||
|
const CONTEXT: &str = "TreeStory";
|
||||||
|
pub(crate) fn init(cx: &mut App) {
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("enter", Rename, Some(CONTEXT)),
|
||||||
|
KeyBinding::new("space", SelectItem, Some(CONTEXT)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TreeStory {
|
||||||
|
tree_state: Entity<TreeState>,
|
||||||
|
selected_item: Option<TreeItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_file_items(ignorer: &Ignorer, root: &PathBuf, path: &PathBuf) -> Vec<TreeItem> {
|
||||||
|
let mut items = Vec::new();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(path) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
let relative_path = path.strip_prefix(root).unwrap_or(&path);
|
||||||
|
if ignorer.is_ignored(&relative_path.to_string_lossy())
|
||||||
|
|| relative_path.ends_with(".git")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let file_name = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("Unknown")
|
||||||
|
.to_string();
|
||||||
|
let id = path.to_string_lossy().to_string();
|
||||||
|
if path.is_dir() {
|
||||||
|
let children = build_file_items(ignorer, &root, &path);
|
||||||
|
items.push(TreeItem::new(id, file_name).children(children));
|
||||||
|
} else {
|
||||||
|
items.push(TreeItem::new(id, file_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
b.is_folder()
|
||||||
|
.cmp(&a.is_folder())
|
||||||
|
.then(a.label.cmp(&b.label))
|
||||||
|
});
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeStory {
|
||||||
|
pub fn view(window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||||
|
cx.new(|cx| Self::new(window, cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_files(state: Entity<TreeState>, path: PathBuf, cx: &mut App) {
|
||||||
|
cx.spawn(async move |cx| {
|
||||||
|
let ignorer = Ignorer::new(&path.to_string_lossy());
|
||||||
|
let items = build_file_items(&ignorer, &path, &path);
|
||||||
|
_ = state.update(cx, |state, cx| {
|
||||||
|
state.set_items(items, cx);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(_: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let tree_state = cx.new(|cx| TreeState::new(cx).items(vec![]));
|
||||||
|
|
||||||
|
Self::load_files(tree_state.clone(), PathBuf::from("./"), cx);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
tree_state,
|
||||||
|
selected_item: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_action_select_item(
|
||||||
|
&mut self,
|
||||||
|
_: &SelectItem,
|
||||||
|
_: &mut Window,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
if let Some(entry) = self.tree_state.read(cx).selected_entry() {
|
||||||
|
self.selected_item = Some(entry.item().clone());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_action_rename(&mut self, _: &Rename, _: &mut Window, cx: &mut gpui::Context<Self>) {
|
||||||
|
if let Some(entry) = self.tree_state.read(cx).selected_entry() {
|
||||||
|
let item = entry.item();
|
||||||
|
println!("Renaming item: {} ({})", item.label, item.id);
|
||||||
|
// Here you could implement actual renaming logic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Story for TreeStory {
|
||||||
|
fn title() -> &'static str {
|
||||||
|
"Tree"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_view(window: &mut Window, cx: &mut App) -> Entity<impl Render> {
|
||||||
|
Self::view(window, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoomable() -> Option<PanelControl> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TreeStory {
|
||||||
|
fn render(
|
||||||
|
&mut self,
|
||||||
|
_: &mut gpui::Window,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) -> impl gpui::IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
v_flex()
|
||||||
|
.id("tree-story")
|
||||||
|
.key_context(CONTEXT)
|
||||||
|
.on_action(cx.listener(Self::on_action_rename))
|
||||||
|
.on_action(cx.listener(Self::on_action_select_item))
|
||||||
|
.gap_5()
|
||||||
|
.child(
|
||||||
|
section("File tree")
|
||||||
|
.sub_title("Press `space` to select, `enter` to rename.")
|
||||||
|
.v_flex()
|
||||||
|
.max_w_md()
|
||||||
|
.child(
|
||||||
|
tree(
|
||||||
|
&self.tree_state,
|
||||||
|
move |ix, entry, _selected, _window, cx| {
|
||||||
|
view.update(cx, |_, cx| {
|
||||||
|
let item = entry.item();
|
||||||
|
let icon = if !entry.is_folder() {
|
||||||
|
IconName::File
|
||||||
|
} else if entry.is_expanded() {
|
||||||
|
IconName::FolderOpen
|
||||||
|
} else {
|
||||||
|
IconName::Folder
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(ix)
|
||||||
|
.w_full()
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.px_3()
|
||||||
|
.pl(px(16.) * entry.depth() + px(12.))
|
||||||
|
.child(
|
||||||
|
h_flex().gap_2().child(icon).child(item.label.clone()),
|
||||||
|
)
|
||||||
|
.on_click(cx.listener({
|
||||||
|
let item = item.clone();
|
||||||
|
move |this, _, _window, cx| {
|
||||||
|
this.selected_item = Some(item.clone());
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.p_1()
|
||||||
|
.border_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.rounded(cx.theme().radius)
|
||||||
|
.h(px(540.)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
h_flex()
|
||||||
|
.w_full()
|
||||||
|
.justify_between()
|
||||||
|
.gap_3()
|
||||||
|
.children(
|
||||||
|
self.tree_state
|
||||||
|
.read(cx)
|
||||||
|
.selected_index()
|
||||||
|
.map(|ix| format!("Selected Index: {}", ix)),
|
||||||
|
)
|
||||||
|
.children(
|
||||||
|
self.selected_item
|
||||||
|
.as_ref()
|
||||||
|
.map(|item| Label::new("Selected:").secondary(item.id.clone())),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -50,7 +50,7 @@ tree-sitter-languages = [
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
gpui = { workspace = true }
|
gpui = { workspace = true, features = ["test-support"] }
|
||||||
gpui-component-macros = { workspace = true }
|
gpui-component-macros = { workspace = true }
|
||||||
gpui-macros.workspace = true
|
gpui-macros.workspace = true
|
||||||
notify.workspace = true
|
notify.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,4 @@ pub struct Confirm {
|
||||||
pub secondary: bool,
|
pub secondary: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
actions!(
|
actions!(ui, [Cancel, SelectUp, SelectDown, SelectLeft, SelectRight]);
|
||||||
list,
|
|
||||||
[Cancel, SelectPrev, SelectNext, SelectLeft, SelectRight]
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ use gpui::{
|
||||||
use rust_i18n::t;
|
use rust_i18n::t;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actions::{Cancel, Confirm, SelectNext, SelectPrev},
|
actions::{Cancel, Confirm, SelectDown, SelectUp},
|
||||||
h_flex,
|
h_flex,
|
||||||
input::clear_button,
|
input::clear_button,
|
||||||
list::{List, ListDelegate},
|
list::{List, ListDelegate},
|
||||||
|
|
@ -29,8 +29,8 @@ pub enum ListEvent {
|
||||||
const CONTEXT: &str = "Dropdown";
|
const CONTEXT: &str = "Dropdown";
|
||||||
pub(crate) fn init(cx: &mut App) {
|
pub(crate) fn init(cx: &mut App) {
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new("up", SelectPrev, Some(CONTEXT)),
|
KeyBinding::new("up", SelectUp, Some(CONTEXT)),
|
||||||
KeyBinding::new("down", SelectNext, Some(CONTEXT)),
|
KeyBinding::new("down", SelectDown, Some(CONTEXT)),
|
||||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
||||||
KeyBinding::new(
|
KeyBinding::new(
|
||||||
"secondary-enter",
|
"secondary-enter",
|
||||||
|
|
@ -603,7 +603,7 @@ where
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn up(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
|
fn up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if !self.open {
|
if !self.open {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
}
|
}
|
||||||
|
|
@ -612,7 +612,7 @@ where
|
||||||
cx.propagate();
|
cx.propagate();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn down(&mut self, _: &SelectNext, window: &mut Window, cx: &mut Context<Self>) {
|
fn down(&mut self, _: &SelectDown, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if !self.open {
|
if !self.open {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ pub enum IconName {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
File,
|
||||||
Folder,
|
Folder,
|
||||||
FolderClosed,
|
FolderClosed,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
|
@ -124,6 +125,7 @@ impl IconName {
|
||||||
Self::ExternalLink => "icons/external-link.svg",
|
Self::ExternalLink => "icons/external-link.svg",
|
||||||
Self::Eye => "icons/eye.svg",
|
Self::Eye => "icons/eye.svg",
|
||||||
Self::EyeOff => "icons/eye-off.svg",
|
Self::EyeOff => "icons/eye-off.svg",
|
||||||
|
Self::File => "icons/file.svg",
|
||||||
Self::Folder => "icons/folder.svg",
|
Self::Folder => "icons/folder.svg",
|
||||||
Self::FolderClosed => "icons/folder-closed.svg",
|
Self::FolderClosed => "icons/folder-closed.svg",
|
||||||
Self::FolderOpen => "icons/folder-open.svg",
|
Self::FolderOpen => "icons/folder-open.svg",
|
||||||
|
|
|
||||||
|
|
@ -254,13 +254,13 @@ impl CodeActionMenu {
|
||||||
|
|
||||||
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.list.update(cx, |this, cx| {
|
self.list.update(cx, |this, cx| {
|
||||||
this.on_action_select_prev(&actions::SelectPrev, window, cx)
|
this.on_action_select_prev(&actions::SelectUp, window, cx)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.list.update(cx, |this, cx| {
|
self.list.update(cx, |this, cx| {
|
||||||
this.on_action_select_next(&actions::SelectNext, window, cx)
|
this.on_action_select_next(&actions::SelectDown, window, cx)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -317,13 +317,13 @@ impl CompletionMenu {
|
||||||
|
|
||||||
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_action_up(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.list.update(cx, |this, cx| {
|
self.list.update(cx, |this, cx| {
|
||||||
this.on_action_select_prev(&actions::SelectPrev, window, cx)
|
this.on_action_select_prev(&actions::SelectUp, window, cx)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_action_down(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.list.update(cx, |this, cx| {
|
self.list.update(cx, |this, cx| {
|
||||||
this.on_action_select_next(&actions::SelectNext, window, cx)
|
this.on_action_select_next(&actions::SelectDown, window, cx)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use gpui::{
|
||||||
use ropey::Rope;
|
use ropey::Rope;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actions::SelectPrev,
|
actions::SelectUp,
|
||||||
button::{Button, ButtonVariants},
|
button::{Button, ButtonVariants},
|
||||||
h_flex,
|
h_flex,
|
||||||
input::{Enter, Escape, IndentInline, InputEvent, InputState, RopeExt as _, Search, TextInput},
|
input::{Enter, Escape, IndentInline, InputEvent, InputState, RopeExt as _, Search, TextInput},
|
||||||
|
|
@ -25,7 +25,7 @@ actions!(input, [Tab]);
|
||||||
pub(super) fn init(cx: &mut App) {
|
pub(super) fn init(cx: &mut App) {
|
||||||
cx.bind_keys(vec![KeyBinding::new(
|
cx.bind_keys(vec![KeyBinding::new(
|
||||||
"shift-enter",
|
"shift-enter",
|
||||||
SelectPrev,
|
SelectUp,
|
||||||
Some(KEY_CONTEXT),
|
Some(KEY_CONTEXT),
|
||||||
)]);
|
)]);
|
||||||
}
|
}
|
||||||
|
|
@ -290,7 +290,7 @@ impl SearchPanel {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn on_action_prev(&mut self, _: &SelectPrev, window: &mut Window, cx: &mut Context<Self>) {
|
fn on_action_prev(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
self.prev(window, cx);
|
self.prev(window, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ use super::{
|
||||||
number_input,
|
number_input,
|
||||||
text_wrapper::TextWrapper,
|
text_wrapper::TextWrapper,
|
||||||
};
|
};
|
||||||
|
use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp};
|
||||||
use crate::input::{
|
use crate::input::{
|
||||||
element::RIGHT_MARGIN,
|
element::RIGHT_MARGIN,
|
||||||
popovers::{ContextMenu, DiagnosticPopover, HoverPopover, MouseContextMenu},
|
popovers::{ContextMenu, DiagnosticPopover, HoverPopover, MouseContextMenu},
|
||||||
|
|
@ -66,10 +67,6 @@ actions!(
|
||||||
MoveEnd,
|
MoveEnd,
|
||||||
MovePageUp,
|
MovePageUp,
|
||||||
MovePageDown,
|
MovePageDown,
|
||||||
SelectUp,
|
|
||||||
SelectDown,
|
|
||||||
SelectLeft,
|
|
||||||
SelectRight,
|
|
||||||
SelectAll,
|
SelectAll,
|
||||||
SelectToStartOfLine,
|
SelectToStartOfLine,
|
||||||
SelectToEndOfLine,
|
SelectToEndOfLine,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ mod root;
|
||||||
mod styled;
|
mod styled;
|
||||||
mod time;
|
mod time;
|
||||||
mod title_bar;
|
mod title_bar;
|
||||||
|
mod tree;
|
||||||
mod virtual_list;
|
mod virtual_list;
|
||||||
mod window_border;
|
mod window_border;
|
||||||
|
|
||||||
|
|
@ -73,11 +74,13 @@ pub use index_path::IndexPath;
|
||||||
pub use input::{Rope, RopeExt, RopeLines};
|
pub use input::{Rope, RopeExt, RopeLines};
|
||||||
#[cfg(any(feature = "inspector", debug_assertions))]
|
#[cfg(any(feature = "inspector", debug_assertions))]
|
||||||
pub use inspector::*;
|
pub use inspector::*;
|
||||||
|
pub use list::*;
|
||||||
pub use menu::{context_menu, popup_menu};
|
pub use menu::{context_menu, popup_menu};
|
||||||
pub use root::{ContextModal, Root};
|
pub use root::{ContextModal, Root};
|
||||||
pub use styled::*;
|
pub use styled::*;
|
||||||
pub use time::*;
|
pub use time::*;
|
||||||
pub use title_bar::*;
|
pub use title_bar::*;
|
||||||
|
pub use tree::*;
|
||||||
pub use virtual_list::{h_virtual_list, v_virtual_list, VirtualList, VirtualListScrollHandle};
|
pub use virtual_list::{h_virtual_list, v_virtual_list, VirtualList, VirtualListScrollHandle};
|
||||||
pub use window_border::{window_border, window_paddings, WindowBorder};
|
pub use window_border::{window_border, window_paddings, WindowBorder};
|
||||||
|
|
||||||
|
|
@ -110,6 +113,7 @@ pub fn init(cx: &mut App) {
|
||||||
menu::init(cx);
|
menu::init(cx);
|
||||||
table::init(cx);
|
table::init(cx);
|
||||||
text::init(cx);
|
text::init(cx);
|
||||||
|
tree::init(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::ops::Range;
|
use std::ops::Range;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev};
|
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
||||||
use crate::input::InputState;
|
use crate::input::InputState;
|
||||||
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
|
use crate::list::cache::{MeasuredEntrySize, RowEntry, RowsCache};
|
||||||
use crate::list::ListDelegate;
|
use crate::list::ListDelegate;
|
||||||
|
|
@ -30,8 +30,8 @@ pub(crate) fn init(cx: &mut App) {
|
||||||
KeyBinding::new("escape", Cancel, context),
|
KeyBinding::new("escape", Cancel, context),
|
||||||
KeyBinding::new("enter", Confirm { secondary: false }, context),
|
KeyBinding::new("enter", Confirm { secondary: false }, context),
|
||||||
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
|
KeyBinding::new("secondary-enter", Confirm { secondary: true }, context),
|
||||||
KeyBinding::new("up", SelectPrev, context),
|
KeyBinding::new("up", SelectUp, context),
|
||||||
KeyBinding::new("down", SelectNext, context),
|
KeyBinding::new("down", SelectDown, context),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -381,7 +381,7 @@ where
|
||||||
|
|
||||||
pub(crate) fn on_action_select_prev(
|
pub(crate) fn on_action_select_prev(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &SelectPrev,
|
_: &SelectUp,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
|
@ -397,7 +397,7 @@ where
|
||||||
|
|
||||||
pub(crate) fn on_action_select_next(
|
pub(crate) fn on_action_select_next(
|
||||||
&mut self,
|
&mut self,
|
||||||
_: &SelectNext,
|
_: &SelectDown,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use crate::{h_flex, ActiveTheme, Disableable, Icon, Selectable, Sizable as _, StyledExt};
|
use crate::{h_flex, ActiveTheme, Disableable, Icon, Selectable, Sizable as _, StyledExt};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
div, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, Div, ElementId,
|
div, prelude::FluentBuilder as _, AnyElement, App, ClickEvent, Div, ElementId,
|
||||||
InteractiveElement, IntoElement, MouseButton, MouseMoveEvent, ParentElement, RenderOnce,
|
InteractiveElement, IntoElement, MouseMoveEvent, ParentElement, RenderOnce, Stateful,
|
||||||
Stateful, StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
StatefulInteractiveElement as _, StyleRefinement, Styled, Window,
|
||||||
};
|
};
|
||||||
use smallvec::SmallVec;
|
use smallvec::SmallVec;
|
||||||
|
|
||||||
|
|
@ -171,18 +171,13 @@ impl RenderOnce for ListItem {
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.refine_style(&self.style)
|
.refine_style(&self.style)
|
||||||
.when(is_selectable, |this| {
|
.when(is_selectable, |this| {
|
||||||
this.when_some(self.on_click, |this, on_click| {
|
this.when_some(self.on_click, |this, on_click| this.on_click(on_click))
|
||||||
this.on_mouse_down(MouseButton::Left, move |_, _, cx| {
|
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
|
||||||
cx.stop_propagation();
|
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
|
||||||
|
})
|
||||||
|
.when(!is_active, |this| {
|
||||||
|
this.hover(|this| this.bg(cx.theme().list_hover))
|
||||||
})
|
})
|
||||||
.on_click(on_click)
|
|
||||||
})
|
|
||||||
.when_some(self.on_mouse_enter, |this, on_mouse_enter| {
|
|
||||||
this.on_mouse_move(move |ev, window, cx| (on_mouse_enter)(ev, window, cx))
|
|
||||||
})
|
|
||||||
.when(!is_active, |this| {
|
|
||||||
this.hover(|this| this.bg(cx.theme().list_hover))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
.when(!is_selectable, |this| {
|
.when(!is_selectable, |this| {
|
||||||
this.text_color(cx.theme().muted_foreground)
|
this.text_color(cx.theme().muted_foreground)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::actions::{Cancel, Confirm, SelectNext, SelectPrev};
|
use crate::actions::{Cancel, Confirm, SelectDown, SelectUp};
|
||||||
use crate::input::{SelectLeft, SelectRight};
|
use crate::actions::{SelectLeft, SelectRight};
|
||||||
use crate::menu::menu_item::MenuItemElement;
|
use crate::menu::menu_item::MenuItemElement;
|
||||||
use crate::scroll::{Scrollbar, ScrollbarState};
|
use crate::scroll::{Scrollbar, ScrollbarState};
|
||||||
use crate::{
|
use crate::{
|
||||||
|
|
@ -22,8 +22,8 @@ pub fn init(cx: &mut App) {
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
|
||||||
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
KeyBinding::new("escape", Cancel, Some(CONTEXT)),
|
||||||
KeyBinding::new("up", SelectPrev, Some(CONTEXT)),
|
KeyBinding::new("up", SelectUp, Some(CONTEXT)),
|
||||||
KeyBinding::new("down", SelectNext, Some(CONTEXT)),
|
KeyBinding::new("down", SelectDown, Some(CONTEXT)),
|
||||||
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
|
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
|
||||||
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
|
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
|
||||||
]);
|
]);
|
||||||
|
|
@ -672,7 +672,7 @@ impl PopupMenu {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_up(&mut self, _: &SelectPrev, _: &mut Window, cx: &mut Context<Self>) {
|
fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
let ix = self.selected_index.unwrap_or(0);
|
let ix = self.selected_index.unwrap_or(0);
|
||||||
|
|
||||||
|
|
@ -691,7 +691,7 @@ impl PopupMenu {
|
||||||
self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
|
self.set_selected_index(last_clickable_ix.unwrap_or(0), cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_down(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
|
fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
cx.stop_propagation();
|
cx.stop_propagation();
|
||||||
let Some(ix) = self.selected_index else {
|
let Some(ix) = self.selected_index else {
|
||||||
self.set_selected_index(0, cx);
|
self.set_selected_index(0, cx);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use std::{ops::Range, rc::Rc, time::Duration};
|
use std::{ops::Range, rc::Rc, time::Duration};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
actions::{Cancel, SelectNext, SelectPrev},
|
actions::{Cancel, SelectDown, SelectUp},
|
||||||
context_menu::ContextMenuExt,
|
context_menu::ContextMenuExt,
|
||||||
h_flex,
|
h_flex,
|
||||||
popup_menu::PopupMenu,
|
popup_menu::PopupMenu,
|
||||||
|
|
@ -30,8 +30,8 @@ pub(crate) fn init(cx: &mut App) {
|
||||||
let context = Some("Table");
|
let context = Some("Table");
|
||||||
cx.bind_keys([
|
cx.bind_keys([
|
||||||
KeyBinding::new("escape", Cancel, context),
|
KeyBinding::new("escape", Cancel, context),
|
||||||
KeyBinding::new("up", SelectPrev, context),
|
KeyBinding::new("up", SelectUp, context),
|
||||||
KeyBinding::new("down", SelectNext, context),
|
KeyBinding::new("down", SelectDown, context),
|
||||||
KeyBinding::new("left", SelectPrevColumn, context),
|
KeyBinding::new("left", SelectPrevColumn, context),
|
||||||
KeyBinding::new("right", SelectNextColumn, context),
|
KeyBinding::new("right", SelectNextColumn, context),
|
||||||
]);
|
]);
|
||||||
|
|
@ -390,7 +390,7 @@ where
|
||||||
cx.propagate();
|
cx.propagate();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn action_select_prev(&mut self, _: &SelectPrev, _: &mut Window, cx: &mut Context<Self>) {
|
fn action_select_prev(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
let rows_count = self.delegate.rows_count(cx);
|
let rows_count = self.delegate.rows_count(cx);
|
||||||
if rows_count < 1 {
|
if rows_count < 1 {
|
||||||
return;
|
return;
|
||||||
|
|
@ -408,7 +408,7 @@ where
|
||||||
self.set_selected_row(selected_row, cx);
|
self.set_selected_row(selected_row, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn action_select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
|
fn action_select_next(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
let rows_count = self.delegate.rows_count(cx);
|
let rows_count = self.delegate.rows_count(cx);
|
||||||
if rows_count < 1 {
|
if rows_count < 1 {
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
540
crates/ui/src/tree.rs
Normal file
540
crates/ui/src/tree.rs
Normal file
|
|
@ -0,0 +1,540 @@
|
||||||
|
use std::{cell::RefCell, rc::Rc};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
div, prelude::FluentBuilder as _, uniform_list, App, Context, ElementId, Entity, FocusHandle,
|
||||||
|
InteractiveElement as _, IntoElement, KeyBinding, ListSizingBehavior, MouseButton,
|
||||||
|
ParentElement, Render, RenderOnce, SharedString, StyleRefinement, Styled,
|
||||||
|
UniformListScrollHandle, Window,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
actions::{Confirm, SelectDown, SelectLeft, SelectRight, SelectUp},
|
||||||
|
scroll::{Scrollbar, ScrollbarState},
|
||||||
|
ListItem, StyledExt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTEXT: &str = "Tree";
|
||||||
|
pub(crate) fn init(cx: &mut App) {
|
||||||
|
cx.bind_keys([
|
||||||
|
KeyBinding::new("up", SelectUp, Some(CONTEXT)),
|
||||||
|
KeyBinding::new("down", SelectDown, Some(CONTEXT)),
|
||||||
|
KeyBinding::new("left", SelectLeft, Some(CONTEXT)),
|
||||||
|
KeyBinding::new("right", SelectRight, Some(CONTEXT)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a [`Tree`].
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `state` - The shared state managing the tree items.
|
||||||
|
/// * `render_item` - A closure to render each tree item.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let state = cx.new(|_| {
|
||||||
|
/// TreeState::new().items(vec![
|
||||||
|
/// TreeItem::new("src")
|
||||||
|
/// .child(TreeItem::new("lib.rs"),
|
||||||
|
/// TreeItem::new("Cargo.toml"),
|
||||||
|
/// TreeItem::new("README.md"),
|
||||||
|
/// ])
|
||||||
|
/// });
|
||||||
|
///
|
||||||
|
/// tree(&state, |ix, entry, selected, window, cx| {
|
||||||
|
/// div().px(px(16.) * entry.depth()).child(item.label.clone())
|
||||||
|
/// })
|
||||||
|
/// ```
|
||||||
|
pub fn tree<R>(state: &Entity<TreeState>, render_item: R) -> Tree
|
||||||
|
where
|
||||||
|
R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
|
||||||
|
{
|
||||||
|
Tree::new(state, render_item)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TreeItemState {
|
||||||
|
expanded: bool,
|
||||||
|
disabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tree item with a label, children, and an expanded state.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TreeItem {
|
||||||
|
pub id: SharedString,
|
||||||
|
pub label: SharedString,
|
||||||
|
pub children: Vec<TreeItem>,
|
||||||
|
state: Rc<RefCell<TreeItemState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flat representation of a tree item with its depth.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct TreeEntry {
|
||||||
|
item: TreeItem,
|
||||||
|
depth: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeEntry {
|
||||||
|
/// Get the source tree item.
|
||||||
|
#[inline]
|
||||||
|
pub fn item(&self) -> &TreeItem {
|
||||||
|
&self.item
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The depth of this item in the tree.
|
||||||
|
#[inline]
|
||||||
|
pub fn depth(&self) -> usize {
|
||||||
|
self.depth
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn is_root(&self) -> bool {
|
||||||
|
self.depth == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this item is a folder (has children).
|
||||||
|
#[inline]
|
||||||
|
pub fn is_folder(&self) -> bool {
|
||||||
|
self.item.is_folder()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true if the item is expanded.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_expanded(&self) -> bool {
|
||||||
|
self.item.is_expanded()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
pub fn is_disabled(&self) -> bool {
|
||||||
|
self.item.is_disabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeItem {
|
||||||
|
/// Create a new tree item with the given label.
|
||||||
|
///
|
||||||
|
/// - The `id` for you to uniquely identify this item, then later you can use it for selection or other purposes.
|
||||||
|
/// - The `label` is the text to display for this item.
|
||||||
|
///
|
||||||
|
/// For example, the `id` is the full file path, and the `label` is the file name.
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// TreeItem::new("src/ui/button.rs", "button.rs")
|
||||||
|
/// ```
|
||||||
|
pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
label: label.into(),
|
||||||
|
children: Vec::new(),
|
||||||
|
state: Rc::new(RefCell::new(TreeItemState {
|
||||||
|
expanded: false,
|
||||||
|
disabled: false,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a child item to this tree item.
|
||||||
|
pub fn child(mut self, child: TreeItem) -> Self {
|
||||||
|
self.children.push(child);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add multiple child items to this tree item.
|
||||||
|
pub fn children(mut self, children: impl Into<Vec<TreeItem>>) -> Self {
|
||||||
|
self.children.extend(children.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set expanded state for this tree item.
|
||||||
|
pub fn expanded(self, expanded: bool) -> Self {
|
||||||
|
self.state.borrow_mut().expanded = expanded;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set disabled state for this tree item.
|
||||||
|
pub fn disabled(self, disabled: bool) -> Self {
|
||||||
|
self.state.borrow_mut().disabled = disabled;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this item is a folder (has children).
|
||||||
|
#[inline]
|
||||||
|
pub fn is_folder(&self) -> bool {
|
||||||
|
self.children.len() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true if the item is disabled.
|
||||||
|
pub fn is_disabled(&self) -> bool {
|
||||||
|
self.state.borrow().disabled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return true if the item is expanded.
|
||||||
|
#[inline]
|
||||||
|
pub fn is_expanded(&self) -> bool {
|
||||||
|
self.state.borrow().expanded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State for managing tree items.
|
||||||
|
pub struct TreeState {
|
||||||
|
focus_handle: FocusHandle,
|
||||||
|
entries: Vec<TreeEntry>,
|
||||||
|
scrollbar_state: ScrollbarState,
|
||||||
|
scroll_handle: UniformListScrollHandle,
|
||||||
|
selected_ix: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TreeState {
|
||||||
|
/// Create a new empty tree state.
|
||||||
|
pub fn new(cx: &mut App) -> Self {
|
||||||
|
Self {
|
||||||
|
selected_ix: None,
|
||||||
|
focus_handle: cx.focus_handle(),
|
||||||
|
scrollbar_state: ScrollbarState::default(),
|
||||||
|
scroll_handle: UniformListScrollHandle::default(),
|
||||||
|
entries: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn items(mut self, items: impl Into<Vec<TreeItem>>) -> Self {
|
||||||
|
let items = items.into();
|
||||||
|
self.entries.clear();
|
||||||
|
for item in items.into_iter() {
|
||||||
|
self.add_entry(item, 0);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_items(&mut self, items: impl Into<Vec<TreeItem>>, cx: &mut Context<Self>) {
|
||||||
|
let items = items.into();
|
||||||
|
self.entries.clear();
|
||||||
|
for item in items.into_iter() {
|
||||||
|
self.add_entry(item, 0);
|
||||||
|
}
|
||||||
|
self.selected_ix = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the currently selected index, if any.
|
||||||
|
pub fn selected_index(&self) -> Option<usize> {
|
||||||
|
self.selected_ix
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the selected index, or `None` to clear selection.
|
||||||
|
pub fn set_selected_index(&mut self, ix: Option<usize>, cx: &mut Context<Self>) {
|
||||||
|
self.selected_ix = ix;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scroll_to_item(&mut self, ix: usize, strategy: gpui::ScrollStrategy) {
|
||||||
|
self.scroll_handle.scroll_to_item(ix, strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the currently selected entry, if any.
|
||||||
|
pub fn selected_entry(&self) -> Option<&TreeEntry> {
|
||||||
|
self.selected_ix.and_then(|ix| self.entries.get(ix))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_entry(&mut self, item: TreeItem, depth: usize) {
|
||||||
|
self.entries.push(TreeEntry {
|
||||||
|
item: item.clone(),
|
||||||
|
depth,
|
||||||
|
});
|
||||||
|
if item.is_expanded() {
|
||||||
|
for child in &item.children {
|
||||||
|
self.add_entry(child.clone(), depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_expand(&mut self, ix: usize) {
|
||||||
|
let Some(entry) = self.entries.get_mut(ix) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !entry.is_folder() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.item.state.borrow_mut().expanded = !entry.is_expanded();
|
||||||
|
self.rebuild_entries();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_entries(&mut self) {
|
||||||
|
let root_items: Vec<TreeItem> = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.is_root())
|
||||||
|
.map(|e| e.item.clone())
|
||||||
|
.collect();
|
||||||
|
self.entries.clear();
|
||||||
|
for item in root_items.into_iter() {
|
||||||
|
self.add_entry(item, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_action_confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Some(selected_ix) = self.selected_ix {
|
||||||
|
if let Some(entry) = self.entries.get(selected_ix) {
|
||||||
|
if entry.is_folder() {
|
||||||
|
self.toggle_expand(selected_ix);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_action_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Some(selected_ix) = self.selected_ix {
|
||||||
|
if let Some(entry) = self.entries.get(selected_ix) {
|
||||||
|
if entry.is_folder() && entry.is_expanded() {
|
||||||
|
self.toggle_expand(selected_ix);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_action_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Some(selected_ix) = self.selected_ix {
|
||||||
|
if let Some(entry) = self.entries.get(selected_ix) {
|
||||||
|
if entry.is_folder() && !entry.is_expanded() {
|
||||||
|
self.toggle_expand(selected_ix);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_action_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let mut selected_ix = self.selected_ix.unwrap_or(0);
|
||||||
|
|
||||||
|
if selected_ix > 0 {
|
||||||
|
selected_ix = selected_ix - 1;
|
||||||
|
} else {
|
||||||
|
selected_ix = self.entries.len().saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selected_ix = Some(selected_ix);
|
||||||
|
self.scroll_handle
|
||||||
|
.scroll_to_item(selected_ix, gpui::ScrollStrategy::Top);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_action_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
let mut selected_ix = self.selected_ix.unwrap_or(0);
|
||||||
|
if selected_ix + 1 < self.entries.len() {
|
||||||
|
selected_ix = selected_ix + 1;
|
||||||
|
} else {
|
||||||
|
selected_ix = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.selected_ix = Some(selected_ix);
|
||||||
|
self.scroll_handle
|
||||||
|
.scroll_to_item(selected_ix, gpui::ScrollStrategy::Bottom);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for TreeState {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tree view element that displays hierarchical data.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct Tree {
|
||||||
|
id: ElementId,
|
||||||
|
state: Entity<TreeState>,
|
||||||
|
style: StyleRefinement,
|
||||||
|
render_item: Rc<dyn Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tree {
|
||||||
|
pub fn new<R>(state: &Entity<TreeState>, render_item: R) -> Self
|
||||||
|
where
|
||||||
|
R: Fn(usize, &TreeEntry, bool, &mut Window, &mut App) -> ListItem + 'static,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: ElementId::Name(format!("tree-{}", state.entity_id()).into()),
|
||||||
|
state: state.clone(),
|
||||||
|
style: StyleRefinement::default(),
|
||||||
|
render_item: Rc::new(move |ix, item, selected, window, app| {
|
||||||
|
render_item(ix, item, selected, window, app)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_entry_click(state: &Entity<TreeState>, ix: usize, _: &mut Window, cx: &mut App) {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.selected_ix = Some(ix);
|
||||||
|
state.toggle_expand(ix);
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Styled for Tree {
|
||||||
|
fn style(&mut self) -> &mut StyleRefinement {
|
||||||
|
&mut self.style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for Tree {
|
||||||
|
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let tree_state = self.state.read(cx);
|
||||||
|
let render_item = self.render_item.clone();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(self.id)
|
||||||
|
.key_context(CONTEXT)
|
||||||
|
.track_focus(&tree_state.focus_handle)
|
||||||
|
.on_action(window.listener_for(&self.state, TreeState::on_action_confirm))
|
||||||
|
.on_action(window.listener_for(&self.state, TreeState::on_action_left))
|
||||||
|
.on_action(window.listener_for(&self.state, TreeState::on_action_right))
|
||||||
|
.on_action(window.listener_for(&self.state, TreeState::on_action_up))
|
||||||
|
.on_action(window.listener_for(&self.state, TreeState::on_action_down))
|
||||||
|
.size_full()
|
||||||
|
.child(
|
||||||
|
uniform_list("entries", tree_state.entries.len(), {
|
||||||
|
let selected_ix = tree_state.selected_ix;
|
||||||
|
let entries = tree_state.entries.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
move |visible_range, window, cx| {
|
||||||
|
let mut items = Vec::with_capacity(visible_range.len());
|
||||||
|
for ix in visible_range {
|
||||||
|
let entry = &entries[ix];
|
||||||
|
let selected = Some(ix) == selected_ix;
|
||||||
|
let item = (render_item)(ix, entry, selected, window, cx);
|
||||||
|
|
||||||
|
let el = div()
|
||||||
|
.id(ix)
|
||||||
|
.child(item.disabled(entry.item().is_disabled()).selected(selected))
|
||||||
|
.when(!entry.item().is_disabled(), |this| {
|
||||||
|
this.on_mouse_down(MouseButton::Left, {
|
||||||
|
let state = state.clone();
|
||||||
|
move |_, window, cx| {
|
||||||
|
Self::on_entry_click(&state, ix, window, cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
items.push(el)
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.flex_grow()
|
||||||
|
.size_full()
|
||||||
|
.track_scroll(tree_state.scroll_handle.clone())
|
||||||
|
.with_sizing_behavior(ListSizingBehavior::Auto)
|
||||||
|
.into_any_element(),
|
||||||
|
)
|
||||||
|
.refine_style(&self.style)
|
||||||
|
.relative()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.right_0()
|
||||||
|
.bottom_0()
|
||||||
|
.w(Scrollbar::width())
|
||||||
|
.child(Scrollbar::vertical(
|
||||||
|
&tree_state.scrollbar_state,
|
||||||
|
&tree_state.scroll_handle,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use indoc::indoc;
|
||||||
|
|
||||||
|
use crate::TreeState;
|
||||||
|
use gpui::AppContext as _;
|
||||||
|
|
||||||
|
fn assert_entries(entries: &Vec<super::TreeEntry>, expected: &str) {
|
||||||
|
let actual: Vec<String> = entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let mut s = String::new();
|
||||||
|
s.push_str(&" ".repeat(e.depth));
|
||||||
|
s.push_str(e.item().label.as_str());
|
||||||
|
s
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let actual = actual.join("\n");
|
||||||
|
assert_eq!(actual.trim(), expected.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn test_tree_entry(cx: &mut gpui::TestAppContext) {
|
||||||
|
use super::TreeItem;
|
||||||
|
|
||||||
|
let items = vec![
|
||||||
|
TreeItem::new("src", "src")
|
||||||
|
.expanded(true)
|
||||||
|
.child(
|
||||||
|
TreeItem::new("src/ui", "ui")
|
||||||
|
.expanded(true)
|
||||||
|
.child(TreeItem::new("src/ui/button.rs", "button.rs"))
|
||||||
|
.child(TreeItem::new("src/ui/icon.rs", "icon.rs"))
|
||||||
|
.child(TreeItem::new("src/ui/mod.rs", "mod.rs")),
|
||||||
|
)
|
||||||
|
.child(TreeItem::new("src/lib.rs", "lib.rs")),
|
||||||
|
TreeItem::new("Cargo.toml", "Cargo.toml"),
|
||||||
|
TreeItem::new("Cargo.lock", "Cargo.lock").disabled(true),
|
||||||
|
TreeItem::new("README.md", "README.md"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let state = cx.new(|cx| TreeState::new(cx).items(items));
|
||||||
|
state.update(cx, |state, _| {
|
||||||
|
assert_entries(
|
||||||
|
&state.entries,
|
||||||
|
indoc! {
|
||||||
|
r#"
|
||||||
|
src
|
||||||
|
ui
|
||||||
|
button.rs
|
||||||
|
icon.rs
|
||||||
|
mod.rs
|
||||||
|
lib.rs
|
||||||
|
Cargo.toml
|
||||||
|
Cargo.lock
|
||||||
|
README.md
|
||||||
|
"#
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry = state.entries.get(0).unwrap();
|
||||||
|
assert_eq!(entry.depth(), 0);
|
||||||
|
assert_eq!(entry.is_root(), true);
|
||||||
|
assert_eq!(entry.is_folder(), true);
|
||||||
|
assert_eq!(entry.is_expanded(), true);
|
||||||
|
|
||||||
|
let entry = state.entries.get(1).unwrap();
|
||||||
|
assert_eq!(entry.depth(), 1);
|
||||||
|
assert_eq!(entry.is_root(), false);
|
||||||
|
assert_eq!(entry.is_folder(), true);
|
||||||
|
assert_eq!(entry.is_expanded(), true);
|
||||||
|
assert_eq!(entry.item().label.as_str(), "ui");
|
||||||
|
|
||||||
|
state.toggle_expand(1);
|
||||||
|
let entry = state.entries.get(1).unwrap();
|
||||||
|
assert_eq!(entry.is_expanded(), false);
|
||||||
|
assert_entries(
|
||||||
|
&state.entries,
|
||||||
|
indoc! {
|
||||||
|
r#"
|
||||||
|
src
|
||||||
|
ui
|
||||||
|
lib.rs
|
||||||
|
Cargo.toml
|
||||||
|
Cargo.lock
|
||||||
|
README.md
|
||||||
|
"#
|
||||||
|
},
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue