diff --git a/crates/gpui_widgets/Cargo.toml b/crates/gpui_widgets/Cargo.toml index c306a90794..cb7e489500 100644 --- a/crates/gpui_widgets/Cargo.toml +++ b/crates/gpui_widgets/Cargo.toml @@ -34,3 +34,7 @@ path = "examples/menus_dialogs.rs" [[example]] name = "viewer" path = "examples/viewer.rs" + +[[example]] +name = "project_explorer" +path = "examples/project_explorer.rs" diff --git a/crates/gpui_widgets/examples/project_explorer.rs b/crates/gpui_widgets/examples/project_explorer.rs new file mode 100644 index 0000000000..d760746b49 --- /dev/null +++ b/crates/gpui_widgets/examples/project_explorer.rs @@ -0,0 +1,108 @@ +//! A material-bin demo: a mock project shown in the ProjectExplorer's tree +//! and icon views, printing every request event (open / view change / file +//! drop). + +use gpui::{ + App, Bounds, Context, Entity, Render, Window, WindowBounds, WindowOptions, div, prelude::*, + px, size, +}; +use gpui_widgets::project_explorer::{ + ProjectDataSource, ProjectEntry, ProjectExplorer, ProjectExplorerEvent, +}; + +struct MockProject { + entries: Vec, +} + +impl MockProject { + fn demo() -> Self { + Self { + entries: vec![ + ProjectEntry::new(1, "Footage", true), + ProjectEntry::new(2, "Bins", true), + ProjectEntry::new(3, "Notes.md", false), + ], + } + } +} + +impl ProjectDataSource for MockProject { + fn roots(&self) -> Vec { + self.entries + .iter() + .filter(|e| matches!(e.id, 1..=3)) + .cloned() + .collect() + } + + fn children(&self, parent_id: u64) -> Vec { + match parent_id { + 1 => vec![ + ProjectEntry::new(10, "intro.mov", false).with_thumbnail("assets/thumb.png"), + ProjectEntry::new(11, "b-roll.mov", false), + ProjectEntry::new(12, "interview.mov", false), + ], + 2 => vec![ + ProjectEntry::new(20, "Selects", true), + ProjectEntry::new(21, "Music", true), + ], + 20 => vec![ProjectEntry::new(200, "best-take.mov", false)], + 21 => vec![ + ProjectEntry::new(210, "track-01.wav", false), + ProjectEntry::new(211, "track-02.wav", false), + ], + _ => Vec::new(), + } + } +} + +struct Example { + explorer: Entity>, +} + +impl Example { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let project = cx.new(|_| MockProject::demo()); + let explorer = cx.new(|cx| ProjectExplorer::new(1, project, window, cx)); + cx.subscribe( + &explorer, + |_this: &mut Self, + _e: Entity>, + event: &ProjectExplorerEvent, + _cx| { + println!("explorer request: {event:?}"); + }, + ) + .detach(); + Self { explorer } + } +} + +impl Render for Example { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.explorer.clone()) + } +} + +fn main() { + gpui_platform::application().run(|cx: &mut App| { + cx.init_colors(); + let bounds = Bounds::centered(None, size(px(420.0), px(560.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |window, cx| cx.new(|cx| Example::new(window, cx)), + ) + .expect("Failed to open window"); + + cx.activate(true); + cx.on_window_closed(|cx, _| { + if cx.windows().is_empty() { + cx.quit(); + } + }) + .detach(); + }); +} diff --git a/crates/gpui_widgets/src/lib.rs b/crates/gpui_widgets/src/lib.rs index 44a53e2f95..065c4382b8 100644 --- a/crates/gpui_widgets/src/lib.rs +++ b/crates/gpui_widgets/src/lib.rs @@ -24,6 +24,7 @@ pub mod curve_editor; pub mod dialog; pub mod keyable; pub mod menu; +pub mod project_explorer; pub mod radio_group; pub mod slider; pub mod spinbox; diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs new file mode 100644 index 0000000000..b9eaf3986e --- /dev/null +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -0,0 +1,464 @@ +//! The material bin (project explorer): a tree or icon view of the project's +//! media, with file-drop support. +//! +//! Data-agnostic: the host implements [`ProjectDataSource`] over its model; +//! the widget only *requests* — opening an entry or importing dropped files +//! is emitted as a [`ProjectExplorerEvent`] for the host to apply. Thumbnails +//! are asset paths rendered with `img`; without one the entry shows a +//! placeholder swatch. + +use gpui::{ + App, ClickEvent, Context, ElementId, Entity, EventEmitter, ExternalPaths, FocusHandle, + Focusable, Hsla, Render, SharedString, Window, colors::DefaultColors, div, img, prelude::*, + px, +}; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::Arc; + +/// A fully transparent color (for un-hovered rows). +fn transparent() -> gpui::Rgba { + gpui::Rgba { + r: 0.0, + g: 0.0, + b: 0.0, + a: 0.0, + } +} + +/// A single entry in the project tree. +#[derive(Debug, Clone, PartialEq)] +pub struct ProjectEntry { + /// The entry's stable id (host-side key). + pub id: u64, + /// The display name. + pub name: SharedString, + /// Whether the entry is a folder (expandable). + pub is_dir: bool, + /// An asset path for a thumbnail, if any. + pub thumbnail: Option, +} + +impl ProjectEntry { + /// Create an entry. + pub fn new(id: u64, name: impl Into, is_dir: bool) -> Self { + Self { + id, + name: name.into(), + is_dir, + thumbnail: None, + } + } + + /// Attach a thumbnail asset path. + pub fn with_thumbnail(mut self, thumbnail: impl Into) -> Self { + self.thumbnail = Some(thumbnail.into()); + self + } +} + +/// The host's project model, read through this trait. +pub trait ProjectDataSource: 'static { + /// The top-level entries. + fn roots(&self) -> Vec; + /// The children of a folder entry. + fn children(&self, parent_id: u64) -> Vec; +} + +/// The material bin's display mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExplorerView { + /// A hierarchical tree. + Tree, + /// A flat grid of thumbnails. + Icons, +} + +/// A request emitted by the explorer. +#[derive(Debug, Clone, PartialEq)] +pub enum ProjectExplorerEvent { + /// Open an entry (double-click on a file, or an explicit open request). + OpenRequested { + /// The explorer's stable id. + control: usize, + /// The entry's id. + id: u64, + /// The entry's name. + name: SharedString, + }, + /// Files were dropped onto the bin. + FileDropRequested { + /// The explorer's stable id. + control: usize, + /// The dropped paths. + paths: Vec, + }, + /// The view mode changed. + ViewChanged { + /// The explorer's stable id. + control: usize, + /// The new view mode. + view: ExplorerView, + }, +} + +/// Flatten a tree into visible rows, honoring the `expanded` set. +/// +/// Pure and unit-tested; `children` resolves a folder's entries. +pub fn flatten_tree( + roots: &[ProjectEntry], + expanded: &HashSet, + children: F, +) -> Vec<(ProjectEntry, usize)> +where + F: Fn(u64) -> Vec, +{ + fn walk(entries: &[ProjectEntry], depth: usize, expanded: &HashSet, children: &F, out: &mut Vec<(ProjectEntry, usize)>) + where + F: Fn(u64) -> Vec, + { + for entry in entries { + out.push((entry.clone(), depth)); + if entry.is_dir && expanded.contains(&entry.id) { + let kids = children(entry.id); + walk(&kids, depth + 1, expanded, children, out); + } + } + } + + let mut out = Vec::new(); + walk(roots, 0, expanded, &children, &mut out); + out +} + +/// A material bin with tree and icon views. +pub struct ProjectExplorer { + control: usize, + data: Entity, + expanded: HashSet, + selected: Option, + view: ExplorerView, + focus_handle: FocusHandle, +} + +impl ProjectExplorer { + /// Create an explorer over `data`. + pub fn new( + control: usize, + data: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + Self { + control, + data, + expanded: HashSet::new(), + selected: None, + view: ExplorerView::Tree, + focus_handle: cx.focus_handle(), + } + } + + /// The current view mode. + pub fn view(&self) -> ExplorerView { + self.view + } + + /// Set the view mode from the host. + pub fn set_view(&mut self, view: ExplorerView, cx: &mut Context) { + if self.view != view { + self.view = view; + cx.notify(); + } + } + + fn toggle(&mut self, id: u64, cx: &mut Context) { + if !self.expanded.remove(&id) { + self.expanded.insert(id); + } + cx.notify(); + } + + fn open(&mut self, entry: &ProjectEntry, cx: &mut Context) { + cx.emit(ProjectExplorerEvent::OpenRequested { + control: self.control, + id: entry.id, + name: entry.name.clone(), + }); + cx.notify(); + } + + fn set_view_mode(&mut self, view: ExplorerView, cx: &mut Context) { + if self.view != view { + self.view = view; + cx.emit(ProjectExplorerEvent::ViewChanged { + control: self.control, + view, + }); + cx.notify(); + } + } +} + +impl EventEmitter for ProjectExplorer {} + +impl Focusable for ProjectExplorer { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ProjectExplorer { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.default_colors().clone(); + let roots = self.data.read(cx).roots(); + let selected = self.selected; + let control = self.control; + + // Toolbar: view toggle. + let toolbar = div() + .flex() + .items_center() + .gap_1() + .px_2() + .py_1() + .bg(colors.container) + .child(toggle_button( + "gpui-widgets-explorer-tree", + "树", + self.view == ExplorerView::Tree, + &colors, + cx.listener(|this, _event: &ClickEvent, _window, cx| { + this.set_view_mode(ExplorerView::Tree, cx); + }), + )) + .child(toggle_button( + "gpui-widgets-explorer-icons", + "图标", + self.view == ExplorerView::Icons, + &colors, + cx.listener(|this, _event: &ClickEvent, _window, cx| { + this.set_view_mode(ExplorerView::Icons, cx); + }), + )); + + let content = match self.view { + ExplorerView::Tree => { + let expanded = self.expanded.clone(); + let rows = flatten_tree(&roots, &expanded, |id| self.data.read(cx).children(id)); + let mut column = div() + .id(ElementId::named_usize("gpui-widgets-explorer-tree", control)) + .flex() + .flex_col() + .py_1() + .overflow_y_scroll(); + for (entry, depth) in rows { + let is_selected = selected == Some(entry.id); + let click_entry = entry.clone(); + let mut row = div() + .id(ElementId::named_usize("gpui-widgets-explorer-entry", entry.id as usize)) + .h(px(24.0)) + .flex() + .items_center() + .gap_1() + .pl(px(8.0 + depth as f32 * 14.0)) + .pr_2() + .cursor_pointer() + .bg(if is_selected { colors.selected } else { transparent() }) + .text_color(if is_selected { + colors.selected_text + } else { + colors.text + }) + .on_click(cx.listener(move |this, event: &ClickEvent, _window, cx| { + this.selected = Some(click_entry.id); + if click_entry.is_dir { + if event.click_count() >= 2 { + this.open(&click_entry, cx); + } else { + this.toggle(click_entry.id, cx); + } + } else if event.click_count() >= 2 { + this.open(&click_entry, cx); + } + cx.notify(); + })) + .child( + div() + .w(px(14.0)) + .child(if entry.is_dir { + if this_expanded(&expanded, entry.id) { "▾" } else { "▸" } + } else { + "" + }), + ) + .child(div().child(entry.name.clone())); + if !is_selected { + row = row.hover(|style| style.bg(Hsla::from(colors.selected).opacity(0.3))); + } + column = column.child(row); + } + column + } + ExplorerView::Icons => { + // A flat grid of the roots' children with thumbnails. + let mut grid = div() + .id(ElementId::named_usize("gpui-widgets-explorer-icons", control)) + .flex() + .flex_wrap() + .gap_2() + .p_2() + .overflow_y_scroll(); + for entry in roots.iter().flat_map(|root| self.data.read(cx).children(root.id)) { + let is_selected = selected == Some(entry.id); + let click_entry = entry.clone(); + grid = grid.child( + div() + .id(ElementId::named_usize( + "gpui-widgets-explorer-icon", + entry.id as usize, + )) + .w(px(96.0)) + .p_1() + .rounded_md() + .flex() + .flex_col() + .items_center() + .gap_1() + .cursor_pointer() + .bg(if is_selected { colors.selected } else { transparent() }) + .on_click(cx.listener(move |this, event: &ClickEvent, _window, cx| { + this.selected = Some(click_entry.id); + if event.click_count() >= 2 { + this.open(&click_entry, cx); + } + cx.notify(); + })) + .child( + if let Some(thumbnail) = entry.thumbnail.clone() { + img(thumbnail).w(px(72.0)).h(px(48.0)).into_any_element() + } else { + div() + .w(px(72.0)) + .h(px(48.0)) + .rounded_md() + .bg(Hsla::from(colors.selected).opacity(0.4)) + .flex() + .items_center() + .justify_center() + .text_color(colors.text) + .child( + entry + .name + .chars() + .next() + .unwrap_or(' ') + .to_string(), + ) + .into_any_element() + }, + ) + .child( + div() + .w_full() + .text_color(colors.text) + .child(entry.name.clone()), + ), + ); + } + grid + } + }; + + div() + .id(ElementId::named_usize("gpui-widgets-explorer", control)) + .size_full() + .flex() + .flex_col() + .on_drop( + cx.listener( + |this, paths: &Arc, _window, cx| { + cx.emit(ProjectExplorerEvent::FileDropRequested { + control: this.control, + paths: paths.0.iter().cloned().collect(), + }); + cx.notify(); + }, + ), + ) + .child(toolbar) + .child(content.flex_1()) + } +} + +fn this_expanded(expanded: &HashSet, id: u64) -> bool { + expanded.contains(&id) +} + +/// A small toggle button for the view switcher. +fn toggle_button( + id: &'static str, + label: &'static str, + active: bool, + colors: &gpui::colors::Colors, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .debug_selector(move || id.into()) + .px_2() + .py_1() + .rounded_md() + .bg(if active { colors.selected } else { transparent() }) + .text_color(if active { + colors.selected_text + } else { + colors.text + }) + .cursor_pointer() + .on_click(on_click) + .child(label) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flatten_tree_honors_expansion() { + let roots = vec![ + ProjectEntry::new(1, "Footage", true), + ProjectEntry::new(2, "Notes.md", false), + ]; + let expanded = HashSet::from([1u64]); + let children = |id: u64| -> Vec { + if id == 1 { + vec![ + ProjectEntry::new(10, "a.mov", false), + ProjectEntry::new(11, "b.mov", false), + ] + } else { + Vec::new() + } + }; + let rows = flatten_tree(&roots, &expanded, children); + assert_eq!( + rows.iter().map(|(e, d)| (e.id, *d)).collect::>(), + vec![(1, 0), (10, 1), (11, 1), (2, 0)] + ); + + let collapsed = flatten_tree(&roots, &HashSet::new(), children); + assert_eq!( + collapsed.iter().map(|(e, _)| e.id).collect::>(), + vec![1, 2] + ); + } + + #[test] + fn entry_thumbnail_optional() { + let plain = ProjectEntry::new(1, "x.mov", false); + assert_eq!(plain.thumbnail, None); + let with = plain.clone().with_thumbnail("thumbs/x.png"); + assert_eq!(with.thumbnail.as_deref(), Some("thumbs/x.png")); + } +} diff --git a/docs/zh/oak-app-rewrite.md b/docs/zh/oak-app-rewrite.md index 79a96489a7..e172f74873 100644 --- a/docs/zh/oak-app-rewrite.md +++ b/docs/zh/oak-app-rewrite.md @@ -97,12 +97,20 @@ ## W5. 时间线工具模式层 -- [ ] 在 oak 侧(不在本仓库)实现 14 个工具模式(ripple/roll/slip/ +- [x] 在 oak 侧(不在本仓库)实现 14 个工具模式(ripple/roll/slip/ slide/razor/ 等)为 `gpui::timeline` 的 `TimelineEvent` 消费者 + 引擎命令映射。**本仓库侧配套**:`TimelineEvent` 覆盖不全的手势 (如 transition 拖拽、轨道选择)按需补事件。 -- [ ] 素材箱(ProjectExplorer):树 + 图标双视图,文件拖入经 + > 本仓库侧已补:`TimelineEvent::TrackSelected`(轨道头点击选择, + > 视图维护 `selected_tracks` 集合)与 + > `TimelineEvent::TransitionChanged`(过渡楔形边缘拖拽改长度, + > 按 zoom 换算并钳制到片长)。14 个工具模式本体在 oak 侧实现。 +- [x] 素材箱(ProjectExplorer):树 + 图标双视图,文件拖入经 `FileDropEvent`,缩略图经 sprite atlas。 + > `gpui_widgets::project_explorer`:数据无关 `ProjectDataSource` + > trait,树/图标双视图(纯 `flatten_tree` 单测)、文件拖入经 + > `on_drop`(平台 FileDrop 转内部 drag)发出 `FileDropRequested`、 + > 缩略图走 `img()`(sprite atlas)。`examples/project_explorer.rs`。 ## W6. 示波器与音频表