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.
This commit is contained in:
2026-08-20 16:20:49 +08:00
parent 0b3e30c095
commit f7a00fdd91
5 changed files with 695 additions and 71 deletions
+381 -21
View File
@@ -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<Bounds<Pixels>>,
}
/// 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<FloatingPanelWindow>,
/// 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<AtomicBool>,
}
/// 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<PanelId>,
drag: Option<DockDragState>,
/// 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<PanelId, FloatingWindowState>,
/// 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<PanelId, Option<DropTarget>>,
/// 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<Self>) -> Option<PanelHandle> {
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<Self>,
) -> 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<Self>) -> 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<Self>) {
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<Self>) {
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<DropTarget> {
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<DropTarget>) -> Option<DropTarget> {
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<DropTarget> {
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<PanelId> {
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<DropTarget> {
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<Self>) {
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<Self>) -> 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<DockHost> = 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<DockHost> = 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::<FloatingPanelWindow>())
.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<DockHost> = 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"
);
}
}
+39 -40
View File
@@ -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<PanelHandle>,
/// Last known window position, mirrored into layout snapshots.
#[allow(dead_code)] // read once floating-window geometry is persisted
origin: Point<Pixels>,
@@ -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<PanelHandle> {
self.panel.take()
}
}
impl Render for FloatingPanelWindow {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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()
+26 -6
View File
@@ -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();
+176 -3
View File
@@ -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<D: TimelineDataSource> TimelineView<D> {
}
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<D: TimelineDataSource> TimelineView<D> {
/// 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<RwLock<ClipDrag>>, cx: &mut Context<Self>) {
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<D: TimelineDataSource> TimelineView<D> {
new_track,
new_start,
});
cx.notify();
}
cx.notify();
}
/// Emits [`TimelineEvent::ClipTrimRequested`] for a finished trim.
@@ -926,6 +930,44 @@ impl<D: TimelineDataSource> Render for TimelineView<D> {
// 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::<Arc<RwLock<ClipDrag>>>()?;
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<D: TimelineDataSource> Render for TimelineView<D> {
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<FrameRange>,
}
/// 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;
+73 -1
View File
@@ -468,12 +468,21 @@ impl<D: ProjectDataSource> Render for ProjectExplorer<D> {
.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<ProjectEntry> {
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<ProjectEntry> {
Vec::new()
}
}
/// Root view hosting the thumbnailed explorer (no event recording needed
/// for the structure assertion).
struct ThumbnailHost {
explorer: Entity<ProjectExplorer<ThumbnailData>>,
}
impl Render for ThumbnailHost {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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"
);
}
}