diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index 8ec3053159..4b55e31fac 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -257,11 +257,17 @@ pub struct TimelineView { focus_handle: FocusHandle, } -/// Width of the track-headers column, in pixels. -const HEADER_WIDTH: f32 = 160.0; +/// Width of the track-headers column, in pixels. Public so host panels can +/// convert drop coordinates into clip-area space. +pub const HEADER_WIDTH: f32 = 160.0; + +/// Height of the ruler row, in pixels. Public so host panels can convert +/// drop coordinates into clip-area space. +pub const RULER_HEIGHT: f32 = 32.0; /// Minimum row height enforced by the height-resize drag, in pixels. -const MIN_TRACK_HEIGHT: f32 = 24.0; +/// Public so host panels can reproduce the track row layout for drops. +pub const MIN_TRACK_HEIGHT: f32 = 24.0; /// Snap engagement threshold, in pixels. const SNAP_THRESHOLD_PX: f32 = 8.0; @@ -890,7 +896,7 @@ impl Render for TimelineView { let ruler = div() .flex() .flex_row() - .h(px(32.)) + .h(px(RULER_HEIGHT)) .flex_shrink_0() .child(div().w(px(HEADER_WIDTH)).flex_shrink_0()) .child( diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs index 51954c20e0..6caab25469 100644 --- a/crates/gpui_widgets/src/project_explorer.rs +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -9,7 +9,8 @@ use gpui::{ App, ClickEvent, Context, ElementId, Entity, EventEmitter, ExternalPaths, FocusHandle, - Focusable, Hsla, Render, SharedString, Window, colors::DefaultColors, div, img, prelude::*, px, + Focusable, Hsla, Point, Pixels, Render, SharedString, Window, colors::DefaultColors, div, + hsla, img, prelude::*, px, }; use std::collections::HashSet; use std::path::PathBuf; @@ -55,6 +56,37 @@ impl ProjectEntry { } } +/// The drag payload of a draggable media entry: the entry's stable id (the +/// same value the host uses as the [`ProjectEntry`] key). Drop targets (the +/// timeline) receive this id and place a clip of the footage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FootageDrag(pub u64); + +/// The ghost rendered under the cursor while dragging a media entry. +struct FootageDragGhost; + +impl Render for FootageDragGhost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + // A small translucent swatch matching the icon placeholder size. + div() + .size(px(72.0)) + .h(px(48.0)) + .rounded_md() + .bg(hsla(0.55, 0.6, 0.5, 0.5)) + } +} + +/// Builds the drag ghost for a footage drag (see +/// [`StatefulInteractiveElement::on_drag`](gpui::StatefulInteractiveElement::on_drag)). +fn footage_drag_ghost( + _drag: &FootageDrag, + _offset: Point, + _window: &mut Window, + cx: &mut App, +) -> Entity { + cx.new(|_cx| FootageDragGhost) +} + /// The host's project model, read through this trait. pub trait ProjectDataSource: 'static { /// The top-level entries. @@ -315,6 +347,11 @@ impl Render for ProjectExplorer { // host's icon set is timeline/viewer tools only). .child(div().w(px(16.0)).child(if entry.is_dir { "▣" } else { "▤" })) .child(div().child(entry.name.clone())); + if !entry.is_dir { + // Media rows are drag sources: the payload carries the + // entry id for the timeline drop target. + row = row.on_drag(FootageDrag(entry.id), footage_drag_ghost); + } if !is_selected { row = row.hover(|style| style.bg(Hsla::from(colors.selected).opacity(0.3))); } @@ -323,7 +360,22 @@ impl Render for ProjectExplorer { column } ExplorerView::Icons => { - // A flat grid of the roots' children with thumbnails. + // A flat grid of the visible media: root-level entries (leaves) + // directly, folder roots expanded to their children — the same + // visible set the tree shows. (The former `children(root)` + // only looped the roots' children, so root-level footage — + // imported media that lives directly in the project root — + // never appeared.) + let visible: Vec = roots + .iter() + .flat_map(|root| { + if root.is_dir { + self.data.read(cx).children(root.id) + } else { + vec![root.clone()] + } + }) + .collect(); let mut grid = div() .id(ElementId::named_usize( "gpui-widgets-explorer-icons", @@ -334,60 +386,64 @@ impl Render for ProjectExplorer { .gap_2() .p_2() .overflow_y_scroll(); - for entry in roots - .iter() - .flat_map(|root| self.data.read(cx).children(root.id)) - { + for entry in visible { 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()), - ), - ); + let entry_id = entry.id; + let mut icon = div() + .id(ElementId::named_usize( + "gpui-widgets-explorer-icon", + entry.id as usize, + )) + .debug_selector(move || { + format!("gpui-widgets-explorer-icon-{entry_id}").into() + }) + .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()), + ); + if !entry.is_dir { + // Media icons are drag sources like the tree rows. + icon = icon.on_drag(FootageDrag(entry.id), footage_drag_ghost); + } + grid = grid.child(icon); } grid } @@ -628,4 +684,37 @@ mod tests { }); assert!(changed, "expected a ViewChanged(Icons) event"); } + + /// The icon grid shows the tree's *visible* set: root-level media + /// entries (leaves) appear directly, folder roots are expanded to their + /// children, and the folder root itself is not shown. Regression test + /// for the old `children(root)` loop, which skipped root-level footage + /// entirely. + #[gpui::test] + async fn icons_view_lists_root_files_and_folder_children(cx: &mut TestAppContext) { + let (cx, _host) = make_explorer(cx); + let toggle = cx + .debug_bounds("gpui-widgets-explorer-icons") + .expect("icons toggle rendered"); + cx.simulate_click(toggle.center(), Modifiers::none()); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + + // MockData roots: folder 1 "Footage" (child 10 "a.mov") and the + // root-level file 2 "Notes.md". + assert!( + cx.debug_bounds("gpui-widgets-explorer-icon-2").is_some(), + "a root-level media entry must show as an icon" + ); + assert!( + cx.debug_bounds("gpui-widgets-explorer-icon-10").is_some(), + "a folder child must show as an icon" + ); + assert!( + cx.debug_bounds("gpui-widgets-explorer-icon-1").is_none(), + "the folder root itself has no icon" + ); + } }