From f7a00fdd911e17a7fdaf4d6b5c888ae2b59a7021 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Thu, 20 Aug 2026 16:20:49 +0800 Subject: [PATCH] feat(dock,timeline): visible tab close buttons, tear-off windows, clip drag ghost, explorer thumbnails - Tab close buttons are now visible and right-aligned with a hover background (they were nearly invisible, causing accidental panel closes). - Tear-off: dragging a tab outside the dock opens the panel in its own floating window; closing the window re-docks the panel to its last group (or closes it outright from the Window menu). - Timeline clip dragging shows a translucent ghost at the target track and frame, including cross-track moves. - Project explorer icon view renders real thumbnail images when the engine provides them, with debug selectors and a structural test. --- crates/gpui/src/dock/dock_area.rs | 402 +++++++++++++++++++- crates/gpui/src/dock/floating.rs | 79 ++-- crates/gpui/src/dock/tab_bar.rs | 32 +- crates/gpui/src/timeline/timeline_view.rs | 179 ++++++++- crates/gpui_widgets/src/project_explorer.rs | 74 +++- 5 files changed, 695 insertions(+), 71 deletions(-) diff --git a/crates/gpui/src/dock/dock_area.rs b/crates/gpui/src/dock/dock_area.rs index 72e418cbf2..43e148cb07 100644 --- a/crates/gpui/src/dock/dock_area.rs +++ b/crates/gpui/src/dock/dock_area.rs @@ -1,6 +1,7 @@ //! The [`DockArea`] view: hosts panels, renders the layout tree, and handles //! drag-to-dock interaction. +use crate::dock::floating::FloatingPanelWindow; use crate::dock::layout::interim_id; use crate::dock::panel::PanelEvent; use crate::dock::split_handle::{SplitHandle, SplitHandleDrag, SplitHandleEvent}; @@ -12,11 +13,13 @@ use crate::dock::{ use crate::{ App, AppContext, Axis, Bounds, Context, Div, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ParentElement, Pixels, Point, Render, - SharedString, Stateful, Styled, Subscription, Window, deferred, div, hsla, px, relative, size, + SharedString, Stateful, Styled, Subscription, Window, WindowBounds, WindowOptions, deferred, + div, hsla, px, relative, size, }; use std::any::Any; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Events emitted by a [`DockArea`]. /// @@ -69,6 +72,17 @@ struct DockDragState { hovered_bounds: Option>, } +/// One tear-off floating window, tracked so the dock can close it +/// programmatically (e.g. from the 窗口 menu) and so its close hook knows +/// whether to re-dock the panel. +struct FloatingWindowState { + /// The window hosting the floated panel. + window: crate::WindowHandle, + /// Set when the shell asks to close the window *without* re-docking; the + /// window's close hook checks it before re-inserting the panel. + suppress_redock: Arc, +} + /// A dockable workspace: renders a [`DockLayout`] tree of panels and manages /// docking interactions. /// @@ -105,6 +119,15 @@ pub struct DockArea { focus_handle: FocusHandle, focused_panel: Option, drag: Option, + /// Tear-off windows (panel id → window), opened by + /// [`float_panel`](DockArea::float_panel) and pruned when the panel + /// re-docks or is closed for good. + floating: HashMap, + /// Each panel's last known dock position, recorded on every layout change + /// and before each removal so a closed/tear-off panel can be re-opened + /// nearby (see [`last_target`](DockArea::last_target)). `None` means the + /// panel was the root. + last_targets: HashMap>, /// One tab-strip entity per `Tabs` node, keyed by the node's current /// path. Re-created when the tree changes shape and pruned each render. /// The subscription keeps the strip's events routed back to this view. @@ -130,6 +153,8 @@ impl DockArea { focus_handle: cx.focus_handle(), focused_panel: None, drag: None, + floating: HashMap::new(), + last_targets: HashMap::new(), tab_bars: HashMap::new(), split_handles: HashMap::new(), } @@ -186,6 +211,9 @@ impl DockArea { /// which honors [`DockPanel::should_close`](crate::dock::DockPanel::should_close). pub fn remove_panel(&mut self, id: PanelId, cx: &mut Context) -> Option { let mut handle = self.panels.remove(&id)?; + // Remember where the panel sat so a reopen (窗口 menu, tear-off + // re-dock) can restore it nearby. + self.last_targets.insert(id, self.target_of(id)); // Dropping the subscription unsubscribes from the panel's events. handle.set_subscription(None); self.layout.remove_panel(id); @@ -348,23 +376,215 @@ impl DockArea { /// Undocks a panel into its own floating window. /// - /// **Deferred**: floating panels depend on unverified multi-window - /// capabilities; see the [`floating`](crate::dock::FloatingPanelWindow) - /// docs. When implemented, this removes the panel from the layout (like - /// [`remove_panel`](DockArea::remove_panel) but without emitting - /// [`DockEvent::PanelRemoved`]) and opens a - /// [`FloatingPanelWindow`](crate::dock::FloatingPanelWindow) hosting it; - /// dropping the window back over a dock area re-docks the panel. Until - /// then, always returns `false`. + /// Removes the panel from the layout (through the same + /// [`remove_panel`](DockArea::remove_panel) flow the tab close button uses, + /// so the position is recorded for a later re-dock) and opens a + /// [`FloatingPanelWindow`](crate::dock::FloatingPanelWindow) hosting it. + /// The window's close hook reclaims the [`PanelHandle`] out of the closing + /// window and re-docks it at the panel's original position; closing the + /// window for good (e.g. via the 窗口 menu, see + /// [`close_floating`](DockArea::close_floating)) skips the re-dock. /// - /// Returns `true` if the panel was floated. - pub fn float_panel( - &mut self, - _id: PanelId, - _window: &mut Window, - _cx: &mut Context, - ) -> bool { - false + /// Returns `false` if the panel is not docked, or already floating. + /// + /// The window is opened from a deferred context by the caller paths + /// ([`finish_drag`](DockArea::finish_drag) / the render-time stale-drag + /// cleanup) because opening a window from inside a mouse-event dispatch + /// can re-enter the app update. + pub fn float_panel(&mut self, id: PanelId, cx: &mut Context) -> bool { + if self.floating.contains_key(&id) || !self.panels.contains_key(&id) { + return false; + } + let Some(handle) = self.remove_panel(id, cx) else { + return false; + }; + let title = handle.title().clone(); + let suppress = Arc::new(AtomicBool::new(false)); + let suppress_close = suppress.clone(); + let dock = cx.weak_entity(); + let floating = cx.new(|cx| FloatingPanelWindow::new(handle, None, cx)); + let root = floating.clone(); + let close_root = floating.clone(); + let bounds = Bounds::centered(None, size(px(640.0), px(480.0)), cx); + let window = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(crate::TitlebarOptions { + title: Some(title.clone()), + appears_transparent: false, + traffic_light_position: None, + }), + ..Default::default() + }, + move |window, app| { + // Re-dock on close: while the window is still alive, lift the + // panel out of its root view and hand it back to the dock. + window.on_window_should_close(app, move |_window, app| { + if suppress_close.load(Ordering::SeqCst) { + // Explicit close (窗口 menu): the panel stays closed. + return true; + } + let panel = close_root.update(app, |floating, _| floating.take_panel()); + let Some(panel) = panel else { + return true; + }; + if let Some(dock) = dock.upgrade() { + let _ = dock.update(app, |dock, cx| dock.redock(panel, cx)); + } + true + }); + root + }, + ); + let Ok(window) = window else { + // Could not open the window; put the panel straight back so it is + // never lost. + let handle = floating.update(cx, |floating, _| floating.take_panel()); + if let Some(handle) = handle { + self.redock(handle, cx); + } + return false; + }; + self.floating.insert( + id, + FloatingWindowState { + window, + suppress_redock: suppress, + }, + ); + true + } + + /// Closes a floating window *without* re-docking the panel (the 窗口 menu + /// uses this to fully close a torn-off panel). No-op if the panel is not + /// floating. + pub fn close_floating(&mut self, id: PanelId, cx: &mut Context) { + let Some(state) = self.floating.remove(&id) else { + return; + }; + state.suppress_redock.store(true, Ordering::SeqCst); + let _ = state.window.update(cx, |_, window, _| window.remove_window()); + } + + /// Re-docks a panel returned by a closing floating window, at the position + /// it had before it was floated. + fn redock(&mut self, panel: PanelHandle, cx: &mut Context) { + let id = panel.panel_id(); + self.floating.remove(&id); + let target = self.fallback_target(self.last_target(id)); + let _ = self.add_panel(panel, target, cx); + } + + /// Returns the last recorded dock position of `panel` (where it sat before + /// its most recent removal, refreshed on every layout change), or `None` + /// if it was the root / has never been docked. + pub fn last_target(&self, id: PanelId) -> Option { + self.last_targets.get(&id).copied().flatten() + } + + /// Whether `panel` is currently docked in the layout. + pub fn is_docked(&self, id: PanelId) -> bool { + self.layout.contains(id) + } + + /// Whether `panel` is currently shown in a floating (tear-off) window. + pub fn is_floating(&self, id: PanelId) -> bool { + self.floating.contains_key(&id) + } + + /// Whether `panel` is currently visible anywhere: docked or floating. + pub fn is_panel_visible(&self, id: PanelId) -> bool { + self.is_docked(id) || self.is_floating(id) + } + + /// Resolves `target` to a usable drop target for re-inserting a panel: if + /// the anchor panel is no longer in the layout, falls back to merging into + /// the first panel that is; `None` (the root) is kept for an empty layout. + pub fn fallback_target(&self, target: Option) -> Option { + match target { + Some(t) if t.panel.map_or(true, |anchor| self.layout.contains(anchor)) => target, + Some(_) => self.layout.panels().first().map(|&anchor| DropTarget { + panel: Some(anchor), + zone: DropZone::Center, + }), + None => None, + } + } + + /// Returns a drop target that would re-insert `panel` at roughly its + /// current position in the layout (its tab group, or beside its split + /// neighbor), so a closed or floated panel can be re-opened nearby. + /// `None` when the panel is the root. + pub fn target_of(&self, panel: PanelId) -> Option { + let root = self.layout.root()?; + Self::target_of_in(root, panel, None) + } + + /// The first panel id in the subtree rooted at `node`, depth-first. + fn first_panel(node: &DockNode) -> Option { + match node { + DockNode::Panel(id) => Some(*id), + DockNode::Tabs { panels, .. } => panels.first().copied(), + DockNode::Split { children, .. } => children.iter().find_map(Self::first_panel), + } + } + + /// Recursive half of [`target_of`](DockArea::target_of); `parent_split` + /// is the split node the current node is a child of, with its index. + fn target_of_in( + node: &DockNode, + panel: PanelId, + parent_split: Option<(&DockNode, usize)>, + ) -> Option { + match node { + DockNode::Panel(id) if *id == panel => { + let (split, index) = parent_split?; + let DockNode::Split { children, .. } = split else { + unreachable!("a Panel leaf's parent is a Split (or the root)") + }; + // Re-insert beside the nearest sibling, using the zone that + // puts the panel on its original side of the neighbor. + if let Some(sibling) = children.get(index + 1) { + Self::first_panel(sibling).map(|anchor| DropTarget { + panel: Some(anchor), + zone: DropZone::Left, + }) + } else if index > 0 { + children.get(index - 1).and_then(Self::first_panel).map(|anchor| { + DropTarget { + panel: Some(anchor), + zone: DropZone::Right, + } + }) + } else { + None + } + } + DockNode::Panel(_) => None, + DockNode::Tabs { panels, .. } => { + if panels.contains(&panel) { + let anchor = panels + .iter() + .find(|&&other| other != panel) + .copied() + .or_else(|| panels.first().copied())?; + Some(DropTarget { + panel: Some(anchor), + zone: DropZone::Center, + }) + } else { + None + } + } + DockNode::Split { children, .. } => { + for (index, child) in children.iter().enumerate() { + if let Some(target) = Self::target_of_in(child, panel, Some((node, index))) { + return Some(target); + } + } + None + } + } } /// Hit-tests a cursor position against the five drop zones of a target. @@ -555,8 +775,13 @@ impl DockArea { } } - /// Emits [`DockEvent::LayoutChanged`] and repaints. + /// Emits [`DockEvent::LayoutChanged`] and repaints. Also refreshes each + /// panel's recorded dock position ([`last_target`](DockArea::last_target)) + /// so a subsequent close can restore it. fn emit_layout_changed(&mut self, cx: &mut Context) { + for id in self.layout.panels() { + self.last_targets.insert(id, self.target_of(id)); + } cx.emit(DockEvent::LayoutChanged); cx.notify(); } @@ -975,10 +1200,23 @@ impl DockArea { impl Render for DockArea { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // A drag that ended without a drop (released outside the dock area) - // leaves transient drag state behind; clear it on the next render. + // A drag that ended without a drop — released outside the dock area + // (or the window) so no `on_drop` ran — leaves transient drag state + // behind. That release is the tear-off gesture: float the panel into + // its own window. The float is deferred out of the render pass, which + // must stay side-effect free. if self.drag.is_some() && !cx.has_active_drag() { - self.drag = None; + if let Some(drag) = self.drag.take() { + let panel = drag.panel; + let this = cx.weak_entity(); + cx.defer(move |app| { + if let Some(this) = this.upgrade() { + this.update(app, |this, cx| { + this.float_panel(panel, cx); + }); + } + }); + } } let mut root = div() @@ -1474,4 +1712,126 @@ mod tests { assert_eq!(dock_layout(&view, cx), "[1,2]"); } + + /// The tab close button renders with a real hitbox at the tab's right edge + /// — not tucked directly after the title — so the ✕ affordance is visible + /// and can't be mis-clicked. + #[test] + fn close_button_is_visible_and_right_aligned() { + let mut test_app = TestAppContext::single(); + let window = open_dock_window(&mut test_app, &[(1, "One"), (2, "Two")], &[]); + let any_window = *window.deref(); + let _view: Entity = window.root(&mut test_app).unwrap(); + let mut cx = VisualTestContext::from_window(any_window, &test_app).into_mut(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + + let tab = cx.debug_bounds("dock-tab-1").expect("tab 1 rendered"); + let close = cx + .debug_bounds("dock-tab-close-1") + .expect("close button rendered for a closable tab"); + assert!( + close.size.width > px(0.0) && close.size.height > px(0.0), + "the close button has a usable hitbox: {close:?}" + ); + // Right-aligned: the button's right edge sits inside the tab's own + // padding (the tab is `px_2` = 8px), not after a short title. With a + // short title and the old inline layout the button would float well + // left of the tab's right edge. + let gap = (tab.right() - close.right()).0; + assert!( + gap >= 0.0 && gap < 14.0, + "close button pins to the tab's right edge (gap {gap}px; tab {tab:?}, close {close:?})" + ); + assert!( + close.left() >= tab.left() && close.right() <= tab.right() + px(1.0), + "close button stays within the tab horizontally" + ); + } + + /// Dragging a tab out of the dock and releasing over no drop target (here, + /// beyond the window) tears the panel off into its own floating window. + /// Closing that window re-docks the panel at its original position. + #[test] + fn dragging_a_tab_outside_the_dock_floats_it_and_closing_re_docks() { + let mut test_app = TestAppContext::single(); + let window = open_dock_window(&mut test_app, &[(1, "One"), (2, "Two")], &[]); + let any_window = *window.deref(); + let view: Entity = window.root(&mut test_app).unwrap(); + let mut cx = VisualTestContext::from_window(any_window, &test_app); + + assert_eq!(dock_layout(&view, &mut cx), "[1,2]"); + + // Drag tab 1 to (900, 300) — past the 800x600 window — and release. + // No drop target is hit, so the dock treats the release as a tear-off. + drag_tab( + &mut cx, + point(px(24.), px(13.)), + point(px(60.), px(43.)), + point(px(900.), px(300.)), + ); + cx.run_until_parked(); + // The next render observes the ended drag and floats the panel. + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + cx.run_until_parked(); + + // The panel left the dock; a second window now hosts it. + assert_eq!(dock_layout(&view, &mut cx), "[2]", "panel 1 was floated"); + let floating = test_app + .windows() + .iter() + .find_map(|handle| handle.downcast::()) + .expect("a floating window hosts the panel"); + let mut float_cx = VisualTestContext::from_window(*floating.deref(), &test_app); + + // Closing the floating window returns the panel to its original group. + assert!( + float_cx.simulate_close(), + "closing the floating window is allowed" + ); + assert_eq!( + dock_layout(&view, &mut cx), + "[2,1]", + "the panel re-docked beside its original group" + ); + } + + /// Closing a floating window for good (the 窗口 menu's toggle on a + /// torn-off panel, see [`DockArea::close_floating`]) removes the panel + /// without re-docking it. + #[test] + fn close_floating_removes_the_panel_without_redocking() { + let mut test_app = TestAppContext::single(); + let window = open_dock_window(&mut test_app, &[(1, "One"), (2, "Two")], &[]); + let any_window = *window.deref(); + let view: Entity = window.root(&mut test_app).unwrap(); + let mut cx = VisualTestContext::from_window(any_window, &test_app); + + // Tear panel 1 off first. + drag_tab( + &mut cx, + point(px(24.), px(13.)), + point(px(60.), px(43.)), + point(px(900.), px(300.)), + ); + cx.run_until_parked(); + cx.update(|window, cx| { + window.draw(cx).clear(); + }); + cx.run_until_parked(); + assert_eq!(dock_layout(&view, &mut cx), "[2]"); + + // Explicitly close the floating window: the panel stays gone. + let dock = cx.read(|app| view.read(app).dock.clone()); + dock.update(&mut cx, |dock, cx| dock.close_floating(PanelId::new(1), cx)); + cx.run_until_parked(); + assert_eq!( + dock_layout(&view, &mut cx), + "[2]", + "an explicit close does not re-dock the panel" + ); + } } diff --git a/crates/gpui/src/dock/floating.rs b/crates/gpui/src/dock/floating.rs index a442e5940a..82d91a56fc 100644 --- a/crates/gpui/src/dock/floating.rs +++ b/crates/gpui/src/dock/floating.rs @@ -1,31 +1,15 @@ //! Floating (undocked) panels. //! -//! # Status -//! -//! Floating panels require spawning one OS window per floated panel, sharing -//! entities across windows, and dragging between windows. GPUI's multi-window -//! support (multiple `cx.open_window` roots sharing an [`App`](crate::App)) -//! is sufficient in principle, but drag-and-drop *across* windows and -//! focus/activation semantics are unverified. Until that is proven, -//! [`DockArea::float_panel`](crate::dock::DockArea::float_panel) always -//! returns `false` and this module's view type is never constructed by the -//! dock machinery itself; it remains available so a caller can host a panel -//! in its own window. -//! -//! # Intended API -//! -//! - [`DockArea::float_panel`](crate::dock::DockArea::float_panel) removes a -//! panel from the layout tree and opens it in its own borderless-chrome -//! window hosting a [`FloatingPanelWindow`]. -//! - [`FloatingPanelWindow`] renders the panel plus a title bar that acts as -//! a drag surface; dropping the window back over a dock area re-docks the -//! panel at the hovered [`DropTarget`](crate::dock::DropTarget). -//! - Floating windows are recorded in -//! [`DockLayoutState::floating`](crate::dock::DockLayoutState::floating) so -//! sessions restore them in place. -//! -//! Everything here is subject to change when the feature is implemented for -//! real. +//! Tearing a tab out of a dock area (dropping it outside the dock) opens the +//! panel in its own OS window hosted by [`FloatingPanelWindow`]; closing that +//! window returns the panel to the dock at its original position. The window +//! lifecycle lives in [`DockArea::float_panel`](crate::dock::DockArea::float_panel): +//! it removes the panel from the layout, opens the window, and registers a +//! close hook that extracts the [`PanelHandle`] back out of the closing +//! window's root view and re-docks it. Closing the window for good (via the +//! Window menu, which has no re-dock) is signalled through a shared +//! [`std::sync::atomic`] flag set by +//! [`DockArea::close_floating`](crate::dock::DockArea::close_floating). use crate::colors::DefaultColors; use crate::dock::PanelHandle; @@ -36,18 +20,16 @@ use crate::{ /// A window hosting a single undocked panel. /// -/// See the [module documentation](crate::dock) — floating support is not yet -/// wired into [`DockArea`](crate::dock::DockArea), so this view is only -/// constructed by callers that host a panel in their own window. -/// -/// The window renders a minimal title bar (panel title, re-dock drag surface, -/// close button) above the panel's view, and reports its bounds back to the -/// owning [`DockArea`](crate::dock::DockArea) so -/// [`DockLayoutState`](crate::dock::DockLayoutState) can restore the window -/// geometry. +/// Created by [`DockArea::float_panel`](crate::dock::DockArea::float_panel) +/// when a tab is dropped outside the dock. The window renders a minimal title +/// bar (panel title) above the panel's view. The [`PanelHandle`] is stored in +/// an `Option` so it can be extracted when the window closes (see +/// [`take_panel`](FloatingPanelWindow::take_panel)) and re-docked by the +/// owning dock area instead of being dropped with the window. pub struct FloatingPanelWindow { - /// The panel hosted by this window. - panel: PanelHandle, + /// The panel hosted by this window, or `None` once the window is closing + /// and the dock area has reclaimed the handle. + panel: Option, /// Last known window position, mirrored into layout snapshots. #[allow(dead_code)] // read once floating-window geometry is persisted origin: Point, @@ -69,15 +51,32 @@ impl FloatingPanelWindow { let origin = initial_bounds .map(|bounds| bounds.get_bounds().origin) .unwrap_or_else(|| Point::new(px(0.0), px(0.0))); - Self { panel, origin } + Self { + panel: Some(panel), + origin, + } + } + + /// Takes the hosted panel out of this window, so the caller can re-dock it + /// after the window closes. + /// + /// Called by the dock area's close hook while the window is still alive; + /// returns `None` on a second call. + pub fn take_panel(&mut self) -> Option { + self.panel.take() } } impl Render for FloatingPanelWindow { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let _ = window; - let title = self.panel.title().clone(); - let view = self.panel.view().clone(); + let Some(panel) = &self.panel else { + // The panel was reclaimed while the window was closing; render an + // empty frame so the teardown is painless. + return div().size_full().bg(cx.default_colors().clone().background); + }; + let title = panel.title().clone(); + let view = panel.view().clone(); div() .flex() .flex_col() diff --git a/crates/gpui/src/dock/tab_bar.rs b/crates/gpui/src/dock/tab_bar.rs index 8657e43089..bf92b6f164 100644 --- a/crates/gpui/src/dock/tab_bar.rs +++ b/crates/gpui/src/dock/tab_bar.rs @@ -97,14 +97,18 @@ impl TabBar { /// Estimated rendered width of a tab for its title: tabs size to their /// content (the design shows full `<面板>·<名称>` labels), so drag /// hit-testing estimates each tab's width from the title — CJK glyphs are - /// full-width, ASCII roughly half — plus the horizontal padding. Only the - /// cached drag geometry uses this; the layout itself measures the text. - fn estimated_width(title: &str) -> Pixels { + /// full-width, ASCII roughly half — plus the horizontal padding and, for + /// closable tabs, the right-aligned close button. Only the cached drag + /// geometry uses this; the layout itself measures the text. + fn estimated_width(title: &str, closable: bool) -> Pixels { let units: f32 = title .chars() .map(|ch| if ch.is_ascii() { 0.55 } else { 1.0 }) .sum(); - Pixels((units * 13.0 + 20.0).max(Self::MIN_TAB_WIDTH.0)) + // The tab's px-2 horizontal padding plus, for closable tabs, the ✕ + // button and its px-0.5 padding. + let chrome = 20.0 + if closable { 16.0 } else { 0.0 }; + Pixels((units * 13.0 + chrome).max(Self::MIN_TAB_WIDTH.0)) } /// Creates a strip for the given tabs; `active` is clamped into range. @@ -224,6 +228,7 @@ impl Render for TabBar { .map(|(index, &panel)| { let width = Self::estimated_width( self.titles.get(index).map(|t| t.as_ref()).unwrap_or(""), + self.closable.get(index).copied().unwrap_or(false), ); let tab = TabGeometry { panel, x: Pixels(x), width }; x += width.0; @@ -293,6 +298,10 @@ impl Render for TabBar { let mut tab = div() .id(ElementId::named_usize("dock-tab", panel.raw() as usize)) + .debug_selector(move || format!("dock-tab-{}", panel.raw())) + .flex() + .flex_row() + .items_center() .min_w(px(Self::MIN_TAB_WIDTH.0)) .px_2() .flex_none() @@ -307,12 +316,15 @@ impl Render for TabBar { colors.background }) .text_color(if active { colors.text } else { colors.disabled }) - .child(title) .on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| { this.activate(index, window, cx); })) .on_drag(panel, ghost_ctor); + // The title fills the tab so the close button pins to its right + // edge; truncation keeps overflowing titles from pushing it out. + tab = tab.child(div().flex_1().min_w_0().truncate().child(title)); + if closable { tab = tab.child( div() @@ -320,9 +332,17 @@ impl Render for TabBar { "dock-tab-close", panel.raw() as usize, )) + .debug_selector(move || format!("dock-tab-close-{}", panel.raw())) + .flex_none() .cursor_pointer() + .rounded_sm() + .px_0p5() .text_xs() - .text_color(colors.disabled) + // Full-contrast text so the affordance is actually + // visible next to the dimmed inactive tab label; the + // hover surfaces the button like the app's chips. + .text_color(colors.text) + .hover(|style| style.bg(colors.container)) .child("✕") .on_click(cx.listener(move |_this, _event: &ClickEvent, _window, cx| { cx.stop_propagation(); diff --git a/crates/gpui/src/timeline/timeline_view.rs b/crates/gpui/src/timeline/timeline_view.rs index 96c588b0b5..4691216335 100644 --- a/crates/gpui/src/timeline/timeline_view.rs +++ b/crates/gpui/src/timeline/timeline_view.rs @@ -70,7 +70,8 @@ use std::sync::{Arc, RwLock}; use crate::{ AnyElement, App, Context, DragMoveEvent, ElementId, Entity, EventEmitter, FocusHandle, Focusable, Hsla, MouseButton, MouseDownEvent, PinchEvent, Pixels, Point, Render, ScrollDelta, - ScrollWheelEvent, SharedString, Window, canvas, div, hsla, prelude::*, px, + ScrollWheelEvent, SharedString, Window, canvas, colors::DefaultColors, div, hsla, prelude::*, + px, }; use super::{ @@ -520,6 +521,8 @@ impl TimelineView { } drag.new_start = new_start; drag.new_track = new_track; + // Repaint so the move ghost follows the resolved target track/frame. + cx.notify(); } /// Updates a trim drag from the pointer position: computes the new @@ -622,6 +625,7 @@ impl TimelineView { /// Emits [`TimelineEvent::ClipMoveRequested`] for a finished clip move, /// unless the gesture didn't move the clip or a locked track was involved. + /// Always repaints so the move ghost disappears after the drop. fn finish_clip_drag(&mut self, drag: &Arc>, cx: &mut Context) { let (clip, original_start, original_track, new_start, new_track) = { let drag = drag.read().expect("clip drag lock is not poisoned"); @@ -642,8 +646,8 @@ impl TimelineView { new_track, new_start, }); - cx.notify(); } + cx.notify(); } /// Emits [`TimelineEvent::ClipTrimRequested`] for a finished trim. @@ -926,6 +930,44 @@ impl Render for TimelineView { // The marquee handler needs the row geometry; keep a snapshot for it // (the clip-area child iterator consumes `rows` below). let marquee_rows = Arc::new(rows.clone()); + + // The clip-move ghost geometry: while a clip drag is active, the + // overlay shows the resolved target track + frame so the user sees + // where the clip will land (including across tracks), matching the + // footage-drop ghost in the host panel. `None` outside a clip drag. + let clip_ghost = cx.active_drag.as_ref().and_then(|drag| { + let drag = drag.value.downcast_ref::>>()?; + let drag = drag.read().ok()?; + Some(clip_ghost_rect( + drag.new_start, + drag.new_track, + drag.original_length, + state.zoom, + state.scroll_offset.x, + &rows, + )) + }); + let clip_ghost_element: AnyElement = match clip_ghost { + Some(rect) => { + let colors = cx.default_colors().clone(); + div() + .absolute() + .left(rect.x) + .top(rect.y) + .w(rect.width) + .h(rect.height) + .rounded_sm() + .border_1() + .border_color(colors.selected) + .bg(crate::Rgba { + a: 0.35, + ..colors.selected + }) + .into_any_element() + } + None => div().into_any_element(), + }; + let playhead_x = state.point_at_frame(state.playhead).0; let decorator = self.decorator.clone(); @@ -1406,12 +1448,16 @@ impl Render for TimelineView { original_track: row_index, new_start: clip.range.start, new_track: row_index, + original_length: clip.range.len(), })), drag_ghost, ) .children(children) })) - })); + })) + // The clip-move ghost overlays the rows (an absolute child of the + // clip area; an empty div when no clip drag is active). + .child(clip_ghost_element); let playhead = div() .absolute() @@ -1468,6 +1514,9 @@ struct ClipDrag { original_track: usize, new_start: Frame, new_track: usize, + /// The clip's own length in frames at drag start (the move ghost's + /// extent). + original_length: Frame, } /// Shared state for a trim gesture; see [`TrimEdge`]. @@ -1525,6 +1574,68 @@ struct ClipRenderData { out_transition: Option, } +/// The on-screen geometry of a clip-move ghost: the translucent overlay shown +/// on the target track while a clip is dragged (the same visual language as +/// the footage-drop ghost the host paints in its timeline panel). +#[derive(Debug, Clone, Copy, PartialEq)] +struct ClipGhostRect { + /// Left edge in clip-area pixels. + x: Pixels, + /// Top edge relative to the clip area's top. + y: Pixels, + /// Width spanning the clip's own length. + width: Pixels, + /// Height of the target track's row. + height: Pixels, +} + +/// Resolves the clip-move ghost rect from a drag's target frame + track and +/// the clip's own length: `x` is `new_start` in clip-area pixels (zoomed and +/// scroll-compensated, matching [`TimelineState::point_at_frame`]), `y` +/// accumulates the row heights above `new_track`, and the rect spans `length` +/// frames at the target row's height. +/// +/// Pure — the frame/track → rect conversion is unit-tested directly. +fn clip_ghost_rect( + new_start: Frame, + new_track: usize, + length: Frame, + zoom: f32, + scroll_x: Pixels, + rows: &[RowData], +) -> ClipGhostRect { + let mut y = 0.0f32; + let mut last_top = 0.0f32; + for row in rows { + if row.index == new_track { + return ClipGhostRect { + x: px(new_start.0 as f32 * zoom) - scroll_x, + y: px(y), + width: px(length.0 as f32 * zoom).max(px(4.0)), + height: px(row.height), + }; + } + last_top = y; + y += row.height; + } + // Target index beyond the last row (a track vanished mid-drag): clamp to + // the last row's band, mirroring `track_at_y`'s fallback. + match rows.last() { + Some(last) => ClipGhostRect { + x: px(new_start.0 as f32 * zoom) - scroll_x, + y: px(last_top), + width: px(length.0 as f32 * zoom).max(px(4.0)), + height: px(last.height), + }, + None => ClipGhostRect { + x: px(new_start.0 as f32 * zoom) - scroll_x, + y: px(0.0), + width: px(length.0 as f32 * zoom).max(px(4.0)), + height: px(MIN_TRACK_HEIGHT), + }, + } +} + /// The ghost rendered under the cursor during any timeline drag. struct DragPreview; @@ -1624,6 +1735,68 @@ mod tests { assert_eq!(reshaped, FrameRange::new(Frame(0), Frame(40))); } + /// A minimal row snapshot for the ghost-rect math (the rect only reads + /// `index` and `height`; the rest stay at inert defaults). + fn ghost_row(index: usize, height: f32) -> RowData { + RowData { + index, + name: SharedString::from(format!("V{}", index + 1)), + kind: TrackKind::Video, + height, + y: 0.0, + locked: false, + muted: false, + solo: false, + visible: true, + clips: Vec::new(), + } + } + + #[test] + fn clip_ghost_rect_maps_target_frame_and_track() { + let rows = vec![ghost_row(0, 48.0), ghost_row(1, 64.0), ghost_row(2, 32.0)]; + // Target frame 100 at zoom 2, scrolled by 40 px: x = 200 - 40. + let rect = clip_ghost_rect(Frame(100), 1, Frame(25), 2.0, px(40.0), &rows); + assert_eq!(rect.x, px(160.0)); + // y accumulates the rows above track 1 (row 0's 48 px). + assert_eq!(rect.y, px(48.0)); + // The ghost spans the clip's own length at the target row's height. + assert_eq!(rect.width, px(50.0)); + assert_eq!(rect.height, px(64.0)); + } + + #[test] + fn clip_ghost_rect_follows_cross_track_drag() { + let rows = vec![ghost_row(0, 48.0), ghost_row(1, 64.0), ghost_row(2, 32.0)]; + // Dragged to track 2: y = 48 + 64, height = row 2's height. + let rect = clip_ghost_rect(Frame(30), 2, Frame(10), 1.0, px(0.0), &rows); + assert_eq!(rect.y, px(112.0)); + assert_eq!(rect.height, px(32.0)); + assert_eq!(rect.x, px(30.0)); + assert_eq!(rect.width, px(10.0)); + } + + #[test] + fn clip_ghost_rect_clamps_out_of_range_track_to_last_row() { + let rows = vec![ghost_row(0, 48.0), ghost_row(1, 64.0)]; + // A track index beyond the last row (track removed mid-drag) lands + // on the last row's band, mirroring `track_at_y`'s fallback. + let rect = clip_ghost_rect(Frame(5), 7, Frame(3), 1.0, px(0.0), &rows); + assert_eq!(rect.y, px(48.0)); + assert_eq!(rect.height, px(64.0)); + + // With no rows at all the rect falls back to the minimum row height. + let empty = clip_ghost_rect(Frame(5), 0, Frame(3), 1.0, px(0.0), &[]); + assert_eq!(empty.height, px(MIN_TRACK_HEIGHT)); + } + + #[test] + fn clip_ghost_rect_never_collapses_below_four_pixels() { + let rows = vec![ghost_row(0, 48.0)]; + let rect = clip_ghost_rect(Frame(0), 0, Frame(1), 0.01, px(0.0), &rows); + assert_eq!(rect.width, px(4.0)); + } + /// A minimal data source for the interaction tests: one video track, no /// clips. struct OneTrackSource; diff --git a/crates/gpui_widgets/src/project_explorer.rs b/crates/gpui_widgets/src/project_explorer.rs index 3fac849c35..ad87ee04ad 100644 --- a/crates/gpui_widgets/src/project_explorer.rs +++ b/crates/gpui_widgets/src/project_explorer.rs @@ -468,12 +468,21 @@ impl Render for ProjectExplorer { .child(if let Some(thumbnail) = entry.thumbnail.clone() { // A filesystem path: load through the path resource so // generated thumbnails resolve without an asset source. - img(PathBuf::from(thumbnail.as_ref())) + // The wrapper div carries the test selector (the `img` + // element itself has no debug bounds). + div() + .debug_selector(move || { + format!("gpui-widgets-explorer-thumb-{entry_id}").into() + }) .w(px(72.0)) .h(px(48.0)) + .child(img(PathBuf::from(thumbnail.as_ref())).w(px(72.0)).h(px(48.0))) .into_any_element() } else { div() + .debug_selector(move || { + format!("gpui-widgets-explorer-thumb-placeholder-{entry_id}").into() + }) .w(px(72.0)) .h(px(48.0)) .rounded_md() @@ -783,4 +792,67 @@ mod tests { "the folder root itself has no icon" ); } + + /// A data source mixing a thumbnailed footage entry with a plain one (the + /// real engine attaches thumbnail paths via + /// [`ProjectEntry::with_thumbnail`]). + struct ThumbnailData; + impl ProjectDataSource for ThumbnailData { + fn roots(&self) -> Vec { + vec![ + ProjectEntry::new(2, "clip.mov", false) + .with_thumbnail("/tmp/oak-thumbnails/thumb2.png"), + ProjectEntry::new(3, "notes.md", false), + ] + } + fn children(&self, _parent_id: u64) -> Vec { + Vec::new() + } + } + + /// Root view hosting the thumbnailed explorer (no event recording needed + /// for the structure assertion). + struct ThumbnailHost { + explorer: Entity>, + } + impl Render for ThumbnailHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child(self.explorer.clone()) + } + } + + /// The icon grid renders a real `img` element for entries carrying a + /// thumbnail asset path and the letter placeholder for entries without + /// one — the widget half of the footage-thumbnail chain. + #[gpui::test] + async fn icon_view_renders_thumbnail_img_or_placeholder(cx: &mut TestAppContext) { + cx.update(|cx| cx.init_colors()); + let window = cx.open_window(size(px(300.0), px(240.0)), |window, cx| { + let data = cx.new(|_| ThumbnailData); + let explorer = cx.new(|cx| ProjectExplorer::new(1, data, window, cx)); + ThumbnailHost { explorer } + }); + cx.run_until_parked(); + let _host = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(window.into(), cx).into_mut(); + + // Switch to the icon grid. + 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(); + }); + + assert!( + cx.debug_bounds("gpui-widgets-explorer-thumb-2").is_some(), + "a thumbnailed entry must render an img element" + ); + assert!( + cx.debug_bounds("gpui-widgets-explorer-thumb-placeholder-3").is_some(), + "an entry without a thumbnail must render the letter placeholder" + ); + } }