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:
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user