Files
coop/crates/ui/src/dock/mod.rs
T
2026-09-18 13:21:40 +07:00

943 lines
30 KiB
Rust

use std::cell::{Cell, RefCell};
use std::ops::Deref as _;
use std::rc::Rc;
use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
Anchor, AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle,
SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _,
WeakEntity, Window, actions, div, px, rems,
};
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
use gpui_base::dock::{
DockAreaRenderer, DockContext, DragPanel, DropIndicator, InsertTarget, NodeId, PaneNode,
PaneRef, PanelId, TabGroupContext, TabGroupRenderer, TileContext, TilesRenderer,
};
use gpui_base::{Placement, ResizeHandleContext, Side};
use theme::{ActiveTheme, TABBAR_HEIGHT};
use crate::button::{Button, ButtonVariants as _};
use crate::menu::DropdownMenu as _;
use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab;
use crate::tab::tab_bar::TabBar;
use crate::title_bar::{title_bar_drag_handlers, window_controls};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
mod panel;
pub use panel::*;
actions!(dock, [ToggleZoom, ClosePanel]);
pub type TitleBarRenderer = fn(&mut Window, &mut App) -> AnyElement;
#[derive(Default)]
pub struct TitleBarChrome {
trailing: Cell<Option<TitleBarRenderer>>,
}
impl TitleBarChrome {
pub fn set_trailing(&self, renderer: TitleBarRenderer) {
self.trailing.set(Some(renderer));
}
fn trailing(&self, window: &mut Window, cx: &mut App) -> Option<AnyElement> {
self.trailing.get().map(|render| render(window, cx))
}
}
pub fn dock_area(
id: impl Into<SharedString>,
window: &mut Window,
cx: &mut App,
) -> (Entity<DockArea>, Rc<TitleBarChrome>) {
let chrome = Rc::new(TitleBarChrome::default());
let shared = Rc::new(SkinShared {
area: RefCell::new(None),
resizing: Cell::new(None),
chrome: chrome.clone(),
});
let area = cx.new(|cx| {
DockArea::new(id, None, window, cx).with_renderer(Rc::new(DockSkin {
shared: shared.clone(),
}))
});
*shared.area.borrow_mut() = Some(area.downgrade());
(area, chrome)
}
pub fn add_panel(
area: &mut DockArea,
panel: PanelHandle,
placement: DockPlacement,
window: &mut Window,
cx: &mut Context<DockArea>,
) {
let key = panel.panel().panel_id(cx);
if let Some((id, node, ix)) = find_panel(area, &key, cx) {
area.move_panel(
id,
InsertTarget::Tabs {
node,
ix: Some(ix),
activate: true,
},
window,
cx,
);
return;
}
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
}
/// The panel in any region of `area` whose logical id is `key`.
fn find_panel(area: &DockArea, key: &SharedString, cx: &App) -> Option<(PanelId, NodeId, usize)> {
let placements = [
DockPlacement::Center,
DockPlacement::Left,
DockPlacement::Right,
DockPlacement::Bottom,
];
for placement in placements {
let Some(tree) = area.layout(placement) else {
continue;
};
for id in tree.panels() {
let matches = area
.panel(id)
.and_then(PanelHandle::of)
.is_some_and(|handle| handle.panel().panel_id(cx) == *key);
if !matches {
continue;
}
let Some(node) = tree.find_panel_node(id) else {
continue;
};
let Some(PaneRef::Tabs { panels, .. }) = tree.find_node(node).map(PaneNode::kind)
else {
continue;
};
let Some(ix) = panels.iter().position(|candidate| *candidate == id) else {
continue;
};
return Some((id, node, ix));
}
}
None
}
pub fn focus_tab_panel(area: &DockArea, window: &mut Window, cx: &mut App) {
let Some(tree) = area.layout(DockPlacement::Center) else {
return;
};
let Some(node) = left_top_group(tree.root()) else {
return;
};
let Some(PaneRef::Tabs { panels, active_ix }) = tree.find_node(node).map(PaneNode::kind) else {
return;
};
let displayed = match panels.get(active_ix) {
Some(panel) if area.panel(*panel).is_some_and(|panel| panel.visible(cx)) => Some(*panel),
_ => panels
.iter()
.copied()
.find(|id| area.panel(*id).is_some_and(|panel| panel.visible(cx))),
};
let Some(panel) = displayed.and_then(|id| area.panel(id).cloned()) else {
return;
};
let focus_handle = panel.focus_handle(cx);
window.focus(&focus_handle, cx);
}
fn left_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { children, .. } => children.first().and_then(left_top_group),
PaneRef::Tiles { .. } => None,
}
}
fn right_top_group(node: &PaneNode) -> Option<NodeId> {
match node.kind() {
PaneRef::Tabs { .. } => Some(node.id()),
PaneRef::Split { axis, children, .. } => {
let child = if axis == gpui::Axis::Vertical {
children.first()
} else {
children.last()
};
child.and_then(right_top_group)
}
PaneRef::Tiles { .. } => None,
}
}
#[derive(Default)]
struct SkinShared {
area: RefCell<Option<WeakEntity<DockArea>>>,
resizing: Cell<Option<DockPlacement>>,
chrome: Rc<TitleBarChrome>,
}
impl SkinShared {
fn area(&self) -> Option<Entity<DockArea>> {
self.area.borrow().as_ref().and_then(|area| area.upgrade())
}
}
struct DockSkin {
shared: Rc<SkinShared>,
}
impl DockSkin {
fn resize_handle(&self, dock: &DockContext) -> impl IntoElement {
let placement = dock.placement();
let shared = self.shared.clone();
let id = match placement {
DockPlacement::Left => "dock-resize-handle-left",
DockPlacement::Right => "dock-resize-handle-right",
DockPlacement::Bottom => "dock-resize-handle-bottom",
DockPlacement::Center => "dock-resize-handle-center",
};
resize_handle(id, placement.axis())
.placement(if placement.is_left() {
Side::Left
} else {
Side::Right
})
.with_appearance(resize_handle_appearance())
.on_drag(DockResizeHandle, move |info, _, _, cx| {
cx.stop_propagation();
shared.resizing.set(Some(placement));
cx.new(|_| info.deref().clone())
})
}
}
impl DockAreaRenderer for DockSkin {
fn render_split_handle(
&self,
handle: &ResizeHandleContext,
window: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
resize_handle_appearance()(handle, window, cx)
}
fn render_dock(
&self,
dock: &DockContext,
content: AnyElement,
_: &mut Window,
_: &mut App,
) -> AnyElement {
div()
.flex()
.size_full()
.relative()
.child(content)
.child(self.resize_handle(dock))
.child(DockResizeTracker {
dock: dock.clone(),
shared: self.shared.clone(),
})
.into_any_element()
}
fn tab_group_renderer(&self) -> Rc<dyn TabGroupRenderer> {
Rc::new(TabGroupSkin::new(self.shared.clone()))
}
fn tiles_renderer(&self) -> Rc<dyn TilesRenderer> {
Rc::new(NoTiles)
}
}
struct NoTiles;
impl TilesRenderer for NoTiles {
fn render_drag_bar(&self, _: &TileContext, _: &mut Window, _: &mut App) -> AnyElement {
Empty.into_any_element()
}
}
/// The payload a dock's resize handle drags; the handle is the affordance.
#[derive(Clone)]
struct DockResizeHandle;
impl Render for DockResizeHandle {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
Empty
}
}
/// Turns the window's mouse stream into dock resizing.
struct DockResizeTracker {
dock: DockContext,
shared: Rc<SkinShared>,
}
impl IntoElement for DockResizeTracker {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl gpui::Element for DockResizeTracker {
type PrepaintState = ();
type RequestLayoutState = ();
fn id(&self) -> Option<gpui::ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
(window.request_layout(Style::default(), None, cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Window,
_: &mut App,
) {
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
_: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
_: &mut Self::PrepaintState,
window: &mut Window,
_: &mut App,
) {
let placement = self.dock.placement();
window.on_mouse_event({
let dock = self.dock.clone();
let shared = self.shared.clone();
move |event: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() || shared.resizing.get() != Some(placement) {
return;
}
let open = shared
.area()
.is_some_and(|area| area.read(cx).is_dock_open(placement));
if !open {
dock.toggle(window, cx);
}
dock.resize_to(event.position, window, cx);
}
});
window.on_mouse_event({
let shared = self.shared.clone();
move |_: &MouseUpEvent, phase, _, _| {
if !phase.bubble() || shared.resizing.get() != Some(placement) {
return;
}
shared.resizing.set(None);
}
});
}
}
struct DragPreview {
panel: Arc<dyn gpui_base::dock::PanelView>,
}
impl Render for DragPreview {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.id("drag-panel")
.cursor_grab()
.p_2()
.min_w_24()
.justify_center()
.overflow_hidden()
.whitespace_nowrap()
.rounded(cx.theme().radius)
.text_sm()
.text_color(cx.theme().text)
.text_ellipsis()
.when(cx.theme().shadow, |this| this.shadow_xs())
.bg(cx.theme().background)
.child(panel_title(&self.panel, cx))
}
}
struct TabGroupSkin {
shared: Rc<SkinShared>,
scroll_handle: ScrollHandle,
last_active_ix: Cell<Option<usize>>,
}
impl TabGroupSkin {
fn new(shared: Rc<SkinShared>) -> Self {
Self {
shared,
scroll_handle: ScrollHandle::new(),
last_active_ix: Cell::new(None),
}
}
fn is_title_bar_group(&self, group: &TabGroupContext, cx: &App) -> bool {
let Some(area) = self.shared.area() else {
return false;
};
area.read(cx)
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root()))
== Some(group.node())
}
fn render_toolbar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement {
let zoomed = group.is_zoomed();
let closable = group.is_closable();
let zoomable = group.active_panel().is_some_and(|panel| panel.zoomable(cx));
let zoom_label = if zoomed { "Zoom Out" } else { "Zoom In" };
let buttons = group
.active_panel()
.and_then(PanelHandle::of)
.map(|handle| handle.panel().toolbar_buttons(window, cx))
.unwrap_or_default();
let menu_panel = group
.active_panel()
.and_then(PanelHandle::of)
.map(|handle| handle.panel().clone());
h_flex()
.p_0p5()
.gap_1p5()
.occlude()
.rounded_full()
.children(buttons.into_iter().map(|button| button.small().ghost()))
.when(zoomed, |this| {
this.child(
Button::new("zoom")
.icon(IconName::Zoom)
.small()
.ghost()
.tooltip("Zoom Out")
.on_click({
let group = TabGroupContext::clone(group);
move |_, window, cx| group.toggle_zoom(window, cx)
}),
)
})
.child(
Button::new("menu")
.icon(IconName::Ellipsis)
.small()
.ghost()
.dropdown_menu({
move |menu, _, cx| {
let menu = match menu_panel.clone() {
Some(panel) => panel.popup_menu(menu, cx),
None => menu,
};
menu.when(zoomable, |this| {
this.separator().menu(zoom_label, Box::new(ToggleZoom))
})
.when(closable, |this| {
this.separator().menu("Close", Box::new(ClosePanel))
})
}
})
.anchor(Anchor::TopRight),
)
}
fn render_title(
&self,
group: &TabGroupContext,
ix: usize,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let panel = group.panels()[ix].clone();
let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some();
let drag = tab_drag(group, ix, cx);
let is_title_bar = self.is_title_bar_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let bar = h_flex()
.id("tab-title-bar")
.justify_between()
.items_center()
.line_height(rems(1.0))
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.when(left_button.is_some(), |this| this.pl_2())
.when(right_button.is_some(), |this| this.pr_2())
.when(has_leading, |this| {
this.child(
h_flex()
.flex_shrink_0()
.mr_1()
.gap_1()
.children(left_button)
.children(bottom_button),
)
})
.child(
div()
.id("tab")
.flex_initial()
.min_w_0()
.px_2()
.overflow_hidden()
.whitespace_nowrap()
.child(
div()
.w_full()
.text_ellipsis()
.text_sm()
.child(panel_title(&panel, cx)),
)
.when_some(drag, |this, drag| {
this.on_drag(drag, {
let panel = panel.clone();
move |drag, offset, _, cx| {
cx.stop_propagation();
drag.set_drag_offset(offset);
cx.new(|_| DragPreview {
panel: panel.clone(),
})
}
})
}),
)
.child({
let space = div().id("tab-title-space").flex_1().h_full();
if is_title_bar {
title_bar_drag_handlers(space, window, cx).into_any_element()
} else {
space.into_any_element()
}
})
.child(
h_flex()
.flex_shrink_0()
.ml_1()
.gap_1()
.child(self.render_toolbar(group, window, cx))
.children(right_button),
)
.when_some(trailing_chrome, |this, chrome| this.child(chrome));
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
}
fn render_tabs(
&self,
group: &TabGroupContext,
visible: &[usize],
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let left_button = self.dock_toggle_button(DockPlacement::Left, group, cx);
let bottom_button = self.dock_toggle_button(DockPlacement::Bottom, group, cx);
let right_button = self.dock_toggle_button(DockPlacement::Right, group, cx);
let has_leading = left_button.is_some() || bottom_button.is_some();
let collapsed = group.is_collapsed();
let droppable = group.is_droppable();
let tabs_count = group.panels().len();
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let displayed_ix = displayed.and_then(|displayed| {
group
.panels()
.iter()
.position(|panel| panel.panel_id(cx) == displayed)
});
let is_title_bar = self.is_title_bar_group(group, cx);
let trailing_chrome = is_title_bar
.then(|| self.shared.chrome.trailing(window, cx))
.flatten();
let empty_space = div()
.id("tab-bar-empty-space")
.h_full()
.flex_grow_1()
.min_w_16()
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| this.bg(cx.theme().surface_background))
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
let ix = (drag.source() == group.node()).then(|| tabs_count - 1);
group.drop_panel(drag.clone(), ix, false, window, cx);
}
})
});
let empty_space = if is_title_bar {
title_bar_drag_handlers(empty_space, window, cx).into_any_element()
} else {
empty_space.into_any_element()
};
let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().panel_background)
.when(is_title_bar || has_leading, |this| {
this.prefix(
h_flex()
.items_center()
.top_0()
.right(-px(1.))
.pl_0p5()
.pr_1()
.children(left_button)
.children(bottom_button),
)
})
.children(visible.iter().map(|ix| {
let ix = *ix;
let panel = group.panels()[ix].clone();
let drag = tab_drag(group, ix, cx);
Tab::new()
.ix(ix)
.tab_bar_prefix(has_leading)
.child(panel_title(&panel, cx))
.selected(!collapsed && displayed_ix == Some(ix))
.disabled(collapsed)
.suffix(
Button::new("close")
.icon(IconName::Close)
.tooltip("Close panel")
.ghost()
.xsmall()
.on_click({
let group = TabGroupContext::clone(group);
let panel = panel.clone();
move |_, window, cx| {
group.close(panel.panel_id(cx), window, cx);
}
}),
)
.on_click({
let group = TabGroupContext::clone(group);
move |_, window, cx| group.select_tab(ix, window, cx)
})
.when(!collapsed, |this| {
this.on_mouse_down(MouseButton::Middle, {
let group = TabGroupContext::clone(group);
let panel = panel.clone();
move |_, window, cx| {
group.close(panel.panel_id(cx), window, cx);
}
})
.when_some(drag, |this, drag| {
this.on_drag(drag, {
let panel = panel.clone();
move |drag, offset, _, cx| {
cx.stop_propagation();
drag.set_drag_offset(offset);
cx.new(|_| DragPreview {
panel: panel.clone(),
})
}
})
})
.when(droppable, |this| {
this.drag_over::<DragPanel>(|this, _, _, cx| {
this.rounded_l_none()
.border_l_2()
.border_r_0()
.border_color(cx.theme().border)
})
.on_drop({
let group = TabGroupContext::clone(group);
move |drag: &DragPanel, window, cx| {
group.drop_panel(drag.clone(), Some(ix), true, window, cx);
}
})
})
})
}))
.last_empty_space(empty_space)
.when(!collapsed, |this| {
this.suffix(
h_flex()
.items_center()
.top_0()
.right_0()
.h_full()
.px_0p5()
.gap_1()
.child(self.render_toolbar(group, window, cx))
.children(right_button)
.children(trailing_chrome),
)
});
if is_title_bar {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.bg(cx.theme().panel_background)
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
} else {
bar.into_any_element()
}
}
fn dock_toggle_button(
&self,
placement: DockPlacement,
group: &TabGroupContext,
cx: &mut App,
) -> Option<Button> {
if group.is_zoomed() {
return None;
}
let area = self.shared.area()?;
let is_open = {
let area = area.read(cx);
if !area.is_dock_collapsible(placement) {
return None;
}
let designated = match placement {
DockPlacement::Left => area
.layout(DockPlacement::Center)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Right => area
.layout(DockPlacement::Center)
.and_then(|tree| right_top_group(tree.root())),
DockPlacement::Bottom => area
.layout(DockPlacement::Bottom)
.and_then(|tree| left_top_group(tree.root())),
DockPlacement::Center => None,
};
if designated != Some(group.node()) {
return None;
}
area.is_dock_open(placement)
};
let icon = match (placement, is_open) {
(DockPlacement::Left, true) => IconName::PanelLeft,
(DockPlacement::Left, false) => IconName::PanelLeftOpen,
(DockPlacement::Right, true) => IconName::PanelRight,
(DockPlacement::Right, false) => IconName::PanelRightOpen,
(DockPlacement::Bottom, true) => IconName::PanelBottom,
(DockPlacement::Bottom, false) => IconName::PanelBottomOpen,
(DockPlacement::Center, _) => return None,
};
Some(
Button::new(SharedString::from(format!("toggle-dock:{:?}", placement)))
.icon(icon)
.small()
.ghost()
.tab_stop(false)
.tooltip(if is_open { "Collapse" } else { "Expand" })
.on_click(move |_, window, cx| {
area.update(cx, |area, cx| area.toggle_dock(placement, window, cx));
}),
)
}
}
impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
div().id("tab-panel").when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
})
}
fn render_tab_bar(
&self,
group: &TabGroupContext,
window: &mut Window,
cx: &mut App,
) -> AnyElement {
let visible: Vec<usize> = group
.panels()
.iter()
.enumerate()
.filter(|(_, panel)| panel.visible(cx))
.map(|(ix, _)| ix)
.collect();
let active_ix = group.active_ix();
if self.last_active_ix.replace(Some(active_ix)) != Some(active_ix)
&& let Some(visible_ix) = visible.iter().position(|ix| *ix == active_ix)
{
self.scroll_handle.scroll_to_item(visible_ix);
}
match visible.as_slice() {
[] => Empty.into_any_element(),
// One panel in a group that is not asking for tabs gets the title
// instead of a tab bar.
[ix] => self.render_title(group, *ix, window, cx),
_ => self.render_tabs(group, visible.as_slice(), window, cx),
}
}
fn render_active_panel(
&self,
panel: AnyView,
group: &TabGroupContext,
_: &mut Window,
cx: &mut App,
) -> AnyElement {
if group.is_collapsed() {
return Empty.into_any_element();
}
v_flex()
.id("tab-content")
.group("")
.overflow_hidden()
.flex_1()
.child(
div()
.size_full()
.bg(cx.theme().panel_background)
.overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
)
.into_any_element()
}
fn render_drop_indicator(
&self,
indicator: DropIndicator,
_: &mut Window,
cx: &mut App,
) -> Option<AnyElement> {
let size = indicator.bounds().size;
let fraction = 0.35;
let (left, top, width, height) = match indicator.placement() {
Some(Placement::Left) => (px(0.), px(0.), size.width * fraction, size.height),
Some(Placement::Right) => (
size.width * (1. - fraction),
px(0.),
size.width * fraction,
size.height,
),
Some(Placement::Top) => (px(0.), px(0.), size.width, size.height * fraction),
Some(Placement::Bottom) => (
px(0.),
size.height * (1. - fraction),
size.width,
size.height * fraction,
),
None => (px(0.), px(0.), size.width, size.height),
};
Some(
div()
.absolute()
.left(left)
.top(top)
.w(width)
.h(height)
.rounded(cx.theme().radius_lg)
.border_1()
.border_color(cx.theme().element_disabled)
.bg(cx.theme().drop_target_background)
.into_any_element(),
)
}
}
fn tab_drag(group: &TabGroupContext, ix: usize, cx: &App) -> Option<DragPanel> {
group
.is_draggable()
.then(|| group.drag_panel(ix, cx))
.flatten()
}
fn panel_title(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> AnyElement {
match PanelHandle::of(panel) {
Some(handle) => handle.panel().title(cx),
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
}
}