feat: out-of-box experience #2
@@ -1,10 +1,3 @@
|
|||||||
//! Dock (DockArea / Dock / Panel) components.
|
|
||||||
//!
|
|
||||||
//! Vendored from [`gpui-component`]'s `dock` module (v0.5.2,
|
|
||||||
//! rev 9e3a29dcbdebc318632bf68203f26c33e9f0e902) so it can be customized
|
|
||||||
//! in-tree. Everything else (Button, TabBar, menus, icons, ...) is imported
|
|
||||||
//! from `gpui-component` directly.
|
|
||||||
|
|
||||||
mod dock;
|
mod dock;
|
||||||
mod invalid_panel;
|
mod invalid_panel;
|
||||||
mod panel;
|
mod panel;
|
||||||
@@ -13,6 +6,7 @@ mod stack_panel;
|
|||||||
mod state;
|
mod state;
|
||||||
mod tab_panel;
|
mod tab_panel;
|
||||||
mod tiles;
|
mod tiles;
|
||||||
|
mod window_controls;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|||||||
+570
-210
@@ -9,23 +9,23 @@ use gpui::prelude::FluentBuilder;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
Anchor, Animation, AnimationExt as _, App, AppContext, Bounds, Context, DismissEvent, Div,
|
Anchor, Animation, AnimationExt as _, App, AppContext, Bounds, Context, DismissEvent, Div,
|
||||||
DragMoveEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
|
DragMoveEvent, Empty, Entity, EntityId, EventEmitter, FocusHandle, Focusable,
|
||||||
InteractiveElement as _, IntoElement, ParentElement, Pixels, Point, Render, ScrollHandle,
|
InteractiveElement as _, IntoElement, MouseButton, ParentElement, Pixels, Point, Render,
|
||||||
SharedString, StatefulInteractiveElement, StyleRefinement, Styled, WeakEntity, Window, div,
|
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement, StyleRefinement, Styled,
|
||||||
point, px, rems,
|
WeakEntity, Window, WindowControlArea, div, point, px, rems,
|
||||||
};
|
};
|
||||||
use gpui_component::animation::{Lerp, ease_out_cubic};
|
use gpui_component::animation::{Lerp, ease_out_cubic};
|
||||||
use gpui_component::button::{Button, ButtonVariants as _};
|
use gpui_component::button::{Button, ButtonVariants as _};
|
||||||
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
use gpui_component::menu::{DropdownMenu, PopupMenu};
|
||||||
use gpui_component::tab::{Tab, TabBar};
|
use gpui_component::tab::{Tab, TabBar};
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
ActiveTheme, AxisExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex,
|
ActiveTheme, AxisExt, ElementExt, IconName, Placement, Selectable, Sizable, h_flex, v_flex,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
AnyDrag, ClosePanel, DockArea, DockEvent, DockPlacement, DropTarget, Panel, PanelControl,
|
AnyDrag, ClosePanel, DockArea, DockEvent, DockPlacement, DropTarget, Panel, PanelControl,
|
||||||
PanelEvent, PanelState, PanelStyle, PanelView, StackPanel, ToggleZoom,
|
PanelEvent, PanelState, PanelStyle, PanelView, StackPanel, ToggleZoom,
|
||||||
};
|
};
|
||||||
use crate::{PanelInfo, t};
|
use crate::{PanelInfo, t, window_controls};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct TabState {
|
struct TabState {
|
||||||
@@ -154,6 +154,17 @@ pub struct TabPanel {
|
|||||||
drop_placeholder_animation_name: SharedString,
|
drop_placeholder_animation_name: SharedString,
|
||||||
/// Is TabPanel used in Tiles.
|
/// Is TabPanel used in Tiles.
|
||||||
in_tiles: bool,
|
in_tiles: bool,
|
||||||
|
|
||||||
|
/// Bounds of the title bar row (the wrapper around the tab bar), in
|
||||||
|
/// window coordinates. Measured via `on_prepaint` to position the
|
||||||
|
/// title-bar drag overlay.
|
||||||
|
title_bar_bounds: Option<Bounds<Pixels>>,
|
||||||
|
/// Bounds of the tab bar's trailing empty space (right after the last
|
||||||
|
/// tab), which marks where the draggable region starts.
|
||||||
|
title_bar_strip_bounds: Option<Bounds<Pixels>>,
|
||||||
|
/// Bounds of the tab bar's suffix (toolbar) area, which marks where the
|
||||||
|
/// draggable region ends.
|
||||||
|
title_bar_suffix_bounds: Option<Bounds<Pixels>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Panel for TabPanel {
|
impl Panel for TabPanel {
|
||||||
@@ -228,6 +239,57 @@ impl Panel for TabPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// State used to move the window when the title bar area is dragged.
|
||||||
|
struct WindowDragState {
|
||||||
|
should_move: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make an element behave like a window title bar: dragging it moves the
|
||||||
|
/// window, and double-clicking zooms the window (or performs the platform's
|
||||||
|
/// default title-bar double-click action on macOS).
|
||||||
|
///
|
||||||
|
/// Only the bar's non-interactive areas should get this — tabs are draggable
|
||||||
|
/// (to reorder panels) and must not move the window.
|
||||||
|
pub fn title_bar_drag_handlers(
|
||||||
|
this: Stateful<Div>,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut App,
|
||||||
|
) -> Stateful<Div> {
|
||||||
|
let state = window.use_state(cx, |_, _| WindowDragState { should_move: false });
|
||||||
|
|
||||||
|
this.window_control_area(WindowControlArea::Drag)
|
||||||
|
.on_mouse_down_out(window.listener_for(&state, |state, _, _, _| {
|
||||||
|
state.should_move = false;
|
||||||
|
}))
|
||||||
|
.on_mouse_down(
|
||||||
|
MouseButton::Left,
|
||||||
|
window.listener_for(&state, |state, _, _, _| {
|
||||||
|
state.should_move = true;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_mouse_up(
|
||||||
|
MouseButton::Left,
|
||||||
|
window.listener_for(&state, |state, _, _, _| {
|
||||||
|
state.should_move = false;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.on_mouse_move(window.listener_for(&state, |state, _, window, _| {
|
||||||
|
if state.should_move {
|
||||||
|
state.should_move = false;
|
||||||
|
window.start_window_move();
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.on_click(|event, window, _| {
|
||||||
|
if event.click_count() == 2 {
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
window.titlebar_double_click();
|
||||||
|
} else {
|
||||||
|
window.zoom_window();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
impl TabPanel {
|
impl TabPanel {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
stack_panel: Option<WeakEntity<StackPanel>>,
|
stack_panel: Option<WeakEntity<StackPanel>>,
|
||||||
@@ -253,6 +315,9 @@ impl TabPanel {
|
|||||||
collapsed: false,
|
collapsed: false,
|
||||||
closable: true,
|
closable: true,
|
||||||
in_tiles: false,
|
in_tiles: false,
|
||||||
|
title_bar_bounds: None,
|
||||||
|
title_bar_strip_bounds: None,
|
||||||
|
title_bar_suffix_bounds: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -751,6 +816,16 @@ impl TabPanel {
|
|||||||
|
|
||||||
let is_bottom_dock = bottom_dock_button.is_some();
|
let is_bottom_dock = bottom_dock_button.is_some();
|
||||||
|
|
||||||
|
// macOS: the traffic lights overlay the window's top-left corner. The
|
||||||
|
// left dock (sidebar) normally clears them; when it is closed or
|
||||||
|
// absent, the center tab bar must reserve the space itself.
|
||||||
|
let needs_traffic_light_padding = cfg!(target_os = "macos")
|
||||||
|
&& self
|
||||||
|
.dock_area
|
||||||
|
.upgrade()
|
||||||
|
.and_then(|area| area.read(cx).left_dock.clone())
|
||||||
|
.is_none_or(|dock| !dock.read(cx).is_open());
|
||||||
|
|
||||||
let panel_style = dock_area.read(cx).panel_style;
|
let panel_style = dock_area.read(cx).panel_style;
|
||||||
let visible_panels = self.visible_panels(cx).collect::<Vec<_>>();
|
let visible_panels = self.visible_panels(cx).collect::<Vec<_>>();
|
||||||
|
|
||||||
@@ -763,58 +838,64 @@ impl TabPanel {
|
|||||||
|
|
||||||
let title_style = panel.title_style(cx);
|
let title_style = panel.title_style(cx);
|
||||||
|
|
||||||
return h_flex()
|
return title_bar_drag_handlers(
|
||||||
.justify_between()
|
h_flex()
|
||||||
.line_height(rems(1.0))
|
.id(("tab-panel-simple-title", cx.entity().entity_id()))
|
||||||
.h(px(30.))
|
.justify_between()
|
||||||
.py_2()
|
.line_height(rems(1.0))
|
||||||
.pl_3()
|
.h(px(30.))
|
||||||
.pr_2()
|
.py_2()
|
||||||
.when(left_dock_button.is_some(), |this| this.pl_2())
|
.pl_3()
|
||||||
.when(right_dock_button.is_some(), |this| this.pr_2())
|
.pr_2()
|
||||||
.when_some(title_style, |this, theme| {
|
.when(left_dock_button.is_some(), |this| this.pl_2())
|
||||||
this.bg(theme.background).text_color(theme.foreground)
|
.when(needs_traffic_light_padding, |this| this.pl(px(80.)))
|
||||||
})
|
.when(right_dock_button.is_some(), |this| this.pr_2())
|
||||||
.when(has_extend_dock_button, |this| {
|
.when_some(title_style, |this, theme| {
|
||||||
this.child(
|
this.bg(theme.background).text_color(theme.foreground)
|
||||||
|
})
|
||||||
|
.when(has_extend_dock_button, |this| {
|
||||||
|
this.child(
|
||||||
|
h_flex()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.mr_1()
|
||||||
|
.gap_1()
|
||||||
|
.children(left_dock_button)
|
||||||
|
.children(bottom_dock_button),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.id("tab")
|
||||||
|
.flex_1()
|
||||||
|
.min_w_16()
|
||||||
|
.overflow_hidden()
|
||||||
|
.text_ellipsis()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.child(panel.title(window, cx))
|
||||||
|
.when(state.draggable, |this| {
|
||||||
|
this.on_drag(
|
||||||
|
DragPanel::new(panel.clone(), view),
|
||||||
|
|drag, offset, _, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
drag.drag_offset.set(offset);
|
||||||
|
cx.new(|_| drag.clone())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.children(panel.title_suffix(window, cx))
|
||||||
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
.mr_1()
|
.ml_1()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.children(left_dock_button)
|
.child(self.render_toolbar(state, window, cx))
|
||||||
.children(bottom_dock_button),
|
.children(right_dock_button),
|
||||||
)
|
),
|
||||||
})
|
window,
|
||||||
.child(
|
cx,
|
||||||
div()
|
)
|
||||||
.id("tab")
|
.into_any_element();
|
||||||
.flex_1()
|
|
||||||
.min_w_16()
|
|
||||||
.overflow_hidden()
|
|
||||||
.text_ellipsis()
|
|
||||||
.whitespace_nowrap()
|
|
||||||
.child(panel.title(window, cx))
|
|
||||||
.when(state.draggable, |this| {
|
|
||||||
this.on_drag(
|
|
||||||
DragPanel::new(panel.clone(), view),
|
|
||||||
|drag, offset, _, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
drag.drag_offset.set(offset);
|
|
||||||
cx.new(|_| drag.clone())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.children(panel.title_suffix(window, cx))
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_shrink_0()
|
|
||||||
.ml_1()
|
|
||||||
.gap_1()
|
|
||||||
.child(self.render_toolbar(state, window, cx))
|
|
||||||
.children(right_dock_button),
|
|
||||||
)
|
|
||||||
.into_any_element();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(panel_ix) = self.pending_scroll_to_ix.take()
|
if let Some(panel_ix) = self.pending_scroll_to_ix.take()
|
||||||
@@ -830,164 +911,268 @@ impl TabPanel {
|
|||||||
|
|
||||||
let tabs_count = self.panels.len();
|
let tabs_count = self.panels.len();
|
||||||
|
|
||||||
TabBar::new("tab-bar")
|
// TabBar lays out its scrollable content at content width, so the
|
||||||
.track_scroll(&self.tab_bar_scroll_handle)
|
// strip after the last tab only spans `min_w_16` — the rest of the
|
||||||
.when(has_extend_dock_button, |this| {
|
// tab bar has no element at all. Cover that dead zone with a
|
||||||
this.prefix(
|
// measured overlay so the whole non-interactive area can drag the
|
||||||
h_flex()
|
// window. Its span is [last tab's right edge, suffix's left edge].
|
||||||
.items_center()
|
let drag_overlay = match (
|
||||||
.top_0()
|
self.title_bar_bounds,
|
||||||
// Right -1 for avoid border overlap with the first tab
|
self.title_bar_strip_bounds,
|
||||||
.right(-px(1.))
|
self.title_bar_suffix_bounds,
|
||||||
.border_r_1()
|
) {
|
||||||
.border_b_1()
|
(Some(title), Some(strip), Some(suffix)) => {
|
||||||
.h_full()
|
let left = strip.left() - title.left();
|
||||||
.border_color(cx.theme().border)
|
let right = title.right() - suffix.left();
|
||||||
.bg(cx.theme().tokens.tab_bar)
|
(left + right < title.size.width).then(|| {
|
||||||
.px_2()
|
title_bar_drag_handlers(
|
||||||
.children(left_dock_button)
|
div()
|
||||||
.children(bottom_dock_button),
|
.id(("title-bar-drag", cx.entity().entity_id()))
|
||||||
)
|
.absolute()
|
||||||
})
|
.top_0()
|
||||||
.children(self.panels.iter().enumerate().filter_map(|(ix, panel)| {
|
.bottom_0()
|
||||||
let mut active = state.active_panel.as_ref() == Some(panel);
|
.left(left)
|
||||||
let droppable = self.collapsed;
|
.right(right)
|
||||||
|
.debug_selector(|| "title-bar-drag".into()),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
if !panel.visible(cx) {
|
div()
|
||||||
return None;
|
.flex()
|
||||||
}
|
.flex_row()
|
||||||
|
.child(
|
||||||
// Always not show active tab style, if the panel is collapsed
|
|
||||||
if self.collapsed {
|
|
||||||
active = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Some(
|
|
||||||
// Note: upstream also calls the crate-private `Tab::ix(ix)` and
|
|
||||||
// `Tab::tab_bar_prefix(...)` here. `TabBar` re-applies `ix` per
|
|
||||||
// child itself, and `tab_bar_prefix` only clears the first
|
|
||||||
// tab's left border when the bar has no prefix — both are
|
|
||||||
// inaccessible from outside gpui-component, so they are
|
|
||||||
// omitted.
|
|
||||||
Tab::new()
|
|
||||||
.map(|this| {
|
|
||||||
if let Some(tab_name) = panel.tab_name(cx) {
|
|
||||||
this.child(tab_name)
|
|
||||||
} else {
|
|
||||||
this.child(panel.title(window, cx))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.selected(active)
|
|
||||||
.on_click(cx.listener({
|
|
||||||
let is_collapsed = self.collapsed;
|
|
||||||
let dock_area = self.dock_area.clone();
|
|
||||||
move |view, _, window, cx| {
|
|
||||||
view.set_active_ix(ix, window, cx);
|
|
||||||
|
|
||||||
// Open dock if clicked on the collapsed bottom dock
|
|
||||||
if is_bottom_dock && is_collapsed {
|
|
||||||
_ = dock_area.update(cx, |dock_area, cx| {
|
|
||||||
dock_area.toggle_dock(DockPlacement::Bottom, window, cx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
.when(!droppable, |this| {
|
|
||||||
this.when(state.draggable, |this| {
|
|
||||||
this.on_drag(
|
|
||||||
DragPanel::new(panel.clone(), view.clone()),
|
|
||||||
|drag, offset, _, cx| {
|
|
||||||
cx.stop_propagation();
|
|
||||||
drag.drag_offset.set(offset);
|
|
||||||
cx.new(|_| drag.clone())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.when(state.droppable, |this| {
|
|
||||||
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
|
||||||
this.rounded_l_none()
|
|
||||||
.border_l_2()
|
|
||||||
.border_r_0()
|
|
||||||
.border_color(cx.theme().drag_border)
|
|
||||||
})
|
|
||||||
.on_drop(cx.listener(move |this, drag: &DragPanel, window, cx| {
|
|
||||||
this.will_split_placement = None;
|
|
||||||
this.on_drop(drag, Some(ix), true, window, cx)
|
|
||||||
}))
|
|
||||||
.when(!self.in_tiles, |this| {
|
|
||||||
this.drag_over::<AnyDrag>(|this, _, _, cx| {
|
|
||||||
this.rounded_l_none()
|
|
||||||
.border_l_2()
|
|
||||||
.border_r_0()
|
|
||||||
.border_color(cx.theme().drag_border)
|
|
||||||
})
|
|
||||||
.on_drop(cx.listener(
|
|
||||||
|this, item: &AnyDrag, _, cx| {
|
|
||||||
this.will_split_placement = None;
|
|
||||||
this.emit_drag_drop(item, None, cx);
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
.last_empty_space(
|
|
||||||
// empty space to allow move to last tab right
|
|
||||||
div()
|
div()
|
||||||
.id("tab-bar-empty-space")
|
.relative()
|
||||||
.h_full()
|
.flex_1()
|
||||||
.flex_grow_1()
|
.min_w_0()
|
||||||
.min_w_16()
|
.on_prepaint({
|
||||||
.when(state.droppable, |this| {
|
let view = view.clone();
|
||||||
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
move |bounds, _, cx| {
|
||||||
this.bg(cx.theme().tokens.drop_target)
|
view.update(cx, |this, cx| {
|
||||||
})
|
if this.title_bar_bounds != Some(bounds) {
|
||||||
.on_drop(cx.listener(move |this, drag: &DragPanel, window, cx| {
|
this.title_bar_bounds = Some(bounds);
|
||||||
this.will_split_placement = None;
|
cx.notify();
|
||||||
|
}
|
||||||
let ix = if drag.tab_panel == view {
|
});
|
||||||
Some(tabs_count - 1)
|
}
|
||||||
} else {
|
})
|
||||||
None
|
.child(
|
||||||
};
|
TabBar::new("tab-bar")
|
||||||
|
.track_scroll(&self.tab_bar_scroll_handle)
|
||||||
this.on_drop(drag, ix, false, window, cx)
|
.when(
|
||||||
}))
|
has_extend_dock_button || needs_traffic_light_padding,
|
||||||
.when(!self.in_tiles, |this| {
|
|this| {
|
||||||
this.drag_over::<AnyDrag>(|this, _, _, cx| {
|
this.prefix(
|
||||||
this.bg(cx.theme().tokens.drop_target)
|
h_flex()
|
||||||
})
|
.items_center()
|
||||||
.on_drop(cx.listener(
|
.top_0()
|
||||||
|this, item: &AnyDrag, _, cx| {
|
// Right -1 for avoid border overlap with the first tab
|
||||||
this.will_split_placement = None;
|
.right(-px(1.))
|
||||||
this.emit_drag_drop(item, None, cx);
|
.border_r_1()
|
||||||
|
.border_b_1()
|
||||||
|
.h_full()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().tokens.tab_bar)
|
||||||
|
.px_2()
|
||||||
|
.when(needs_traffic_light_padding, |this| {
|
||||||
|
this.pl(px(80.))
|
||||||
|
})
|
||||||
|
.children(left_dock_button)
|
||||||
|
.children(bottom_dock_button),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
))
|
)
|
||||||
})
|
.children(self.panels.iter().enumerate().filter_map(|(ix, panel)| {
|
||||||
}),
|
let mut active = state.active_panel.as_ref() == Some(panel);
|
||||||
|
let droppable = self.collapsed;
|
||||||
|
|
||||||
|
if !panel.visible(cx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always not show active tab style, if the panel is collapsed
|
||||||
|
if self.collapsed {
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(
|
||||||
|
// Note: upstream also calls the crate-private `Tab::ix(ix)` and
|
||||||
|
// `Tab::tab_bar_prefix(...)` here. `TabBar` re-applies `ix` per
|
||||||
|
// child itself, and `tab_bar_prefix` only clears the first
|
||||||
|
// tab's left border when the bar has no prefix — both are
|
||||||
|
// inaccessible from outside gpui-component, so they are
|
||||||
|
// omitted.
|
||||||
|
Tab::new()
|
||||||
|
.map(|this| {
|
||||||
|
if let Some(tab_name) = panel.tab_name(cx) {
|
||||||
|
this.child(tab_name)
|
||||||
|
} else {
|
||||||
|
this.child(panel.title(window, cx))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.selected(active)
|
||||||
|
.on_click(cx.listener({
|
||||||
|
let is_collapsed = self.collapsed;
|
||||||
|
let dock_area = self.dock_area.clone();
|
||||||
|
move |view, _, window, cx| {
|
||||||
|
view.set_active_ix(ix, window, cx);
|
||||||
|
|
||||||
|
// Open dock if clicked on the collapsed bottom dock
|
||||||
|
if is_bottom_dock && is_collapsed {
|
||||||
|
_ = dock_area.update(cx, |dock_area, cx| {
|
||||||
|
dock_area.toggle_dock(
|
||||||
|
DockPlacement::Bottom,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.when(!droppable, |this| {
|
||||||
|
this.when(state.draggable, |this| {
|
||||||
|
this.on_drag(
|
||||||
|
DragPanel::new(panel.clone(), view.clone()),
|
||||||
|
|drag, offset, _, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
drag.drag_offset.set(offset);
|
||||||
|
cx.new(|_| drag.clone())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.when(
|
||||||
|
state.droppable,
|
||||||
|
|this| {
|
||||||
|
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
||||||
|
this.rounded_l_none()
|
||||||
|
.border_l_2()
|
||||||
|
.border_r_0()
|
||||||
|
.border_color(cx.theme().drag_border)
|
||||||
|
})
|
||||||
|
.on_drop(cx.listener(
|
||||||
|
move |this, drag: &DragPanel, window, cx| {
|
||||||
|
this.will_split_placement = None;
|
||||||
|
this.on_drop(drag, Some(ix), true, window, cx)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.when(
|
||||||
|
!self.in_tiles,
|
||||||
|
|this| {
|
||||||
|
this.drag_over::<AnyDrag>(|this, _, _, cx| {
|
||||||
|
this.rounded_l_none()
|
||||||
|
.border_l_2()
|
||||||
|
.border_r_0()
|
||||||
|
.border_color(cx.theme().drag_border)
|
||||||
|
})
|
||||||
|
.on_drop(cx.listener(
|
||||||
|
|this, item: &AnyDrag, _, cx| {
|
||||||
|
this.will_split_placement = None;
|
||||||
|
this.emit_drag_drop(item, None, cx);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.last_empty_space(
|
||||||
|
// Empty space to allow dropping at the last tab. Its left
|
||||||
|
// edge (right after the last tab) marks the start of the
|
||||||
|
// title-bar drag overlay.
|
||||||
|
div()
|
||||||
|
.id("tab-bar-empty-space")
|
||||||
|
.h_full()
|
||||||
|
.flex_grow_1()
|
||||||
|
.min_w_16()
|
||||||
|
.on_prepaint({
|
||||||
|
let view = view.clone();
|
||||||
|
move |bounds, _, cx| {
|
||||||
|
view.update(cx, |this, cx| {
|
||||||
|
if this.title_bar_strip_bounds != Some(bounds) {
|
||||||
|
this.title_bar_strip_bounds = Some(bounds);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.when(state.droppable, |this| {
|
||||||
|
let view = view.clone();
|
||||||
|
this.drag_over::<DragPanel>(|this, _, _, cx| {
|
||||||
|
this.bg(cx.theme().tokens.drop_target)
|
||||||
|
})
|
||||||
|
.on_drop(cx.listener(
|
||||||
|
move |this, drag: &DragPanel, window, cx| {
|
||||||
|
this.will_split_placement = None;
|
||||||
|
|
||||||
|
let ix = if drag.tab_panel == view {
|
||||||
|
Some(tabs_count - 1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
this.on_drop(drag, ix, false, window, cx)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.when(
|
||||||
|
!self.in_tiles,
|
||||||
|
|this| {
|
||||||
|
this.drag_over::<AnyDrag>(|this, _, _, cx| {
|
||||||
|
this.bg(cx.theme().tokens.drop_target)
|
||||||
|
})
|
||||||
|
.on_drop(cx.listener(
|
||||||
|
|this, item: &AnyDrag, _, cx| {
|
||||||
|
this.will_split_placement = None;
|
||||||
|
this.emit_drag_drop(item, None, cx);
|
||||||
|
},
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when(!self.collapsed, |this| {
|
||||||
|
this.suffix(
|
||||||
|
h_flex()
|
||||||
|
.items_center()
|
||||||
|
.top_0()
|
||||||
|
.right_0()
|
||||||
|
.border_l_1()
|
||||||
|
.border_b_1()
|
||||||
|
.h_full()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().tokens.tab_bar)
|
||||||
|
.px_2()
|
||||||
|
.gap_1()
|
||||||
|
.on_prepaint({
|
||||||
|
let view = view.clone();
|
||||||
|
move |bounds, _, cx| {
|
||||||
|
view.update(cx, |this, cx| {
|
||||||
|
if this.title_bar_suffix_bounds != Some(bounds)
|
||||||
|
{
|
||||||
|
this.title_bar_suffix_bounds = Some(bounds);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.children(
|
||||||
|
self.active_panel(cx)
|
||||||
|
.and_then(|panel| panel.title_suffix(window, cx)),
|
||||||
|
)
|
||||||
|
.child(self.render_toolbar(state, window, cx))
|
||||||
|
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.when_some(drag_overlay, |this, overlay| this.child(overlay)),
|
||||||
)
|
)
|
||||||
.when(!self.collapsed, |this| {
|
.child(window_controls::window_controls(window, cx))
|
||||||
this.suffix(
|
|
||||||
h_flex()
|
|
||||||
.items_center()
|
|
||||||
.top_0()
|
|
||||||
.right_0()
|
|
||||||
.border_l_1()
|
|
||||||
.border_b_1()
|
|
||||||
.h_full()
|
|
||||||
.border_color(cx.theme().border)
|
|
||||||
.bg(cx.theme().tokens.tab_bar)
|
|
||||||
.px_2()
|
|
||||||
.gap_1()
|
|
||||||
.children(
|
|
||||||
self.active_panel(cx)
|
|
||||||
.and_then(|panel| panel.title_suffix(window, cx)),
|
|
||||||
)
|
|
||||||
.child(self.render_toolbar(state, window, cx))
|
|
||||||
.when_some(right_dock_button, |this, btn| this.child(btn)),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1494,9 +1679,11 @@ impl Render for TabPanel {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
use gpui::{TestAppContext, VisualTestContext, WindowHandle};
|
use gpui::{TestAppContext, VisualTestContext, WindowHandle, WindowOptions, size};
|
||||||
use gpui_component::{Root, Theme};
|
use gpui_component::tab::{Tab, TabBar};
|
||||||
|
use gpui_component::{Root, Theme, v_flex};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{DockItem, TileMeta};
|
use crate::{DockItem, TileMeta};
|
||||||
@@ -2099,4 +2286,177 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(drain(&fixture.log), [("A", false)]);
|
assert_eq!(drain(&fixture.log), [("A", false)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProbeFlags {
|
||||||
|
empty_down: AtomicBool,
|
||||||
|
empty_click: AtomicBool,
|
||||||
|
control_down: AtomicBool,
|
||||||
|
control_click: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ProbeView {
|
||||||
|
flags: Entity<ProbeFlags>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ProbeView {
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let flags = self.flags.clone();
|
||||||
|
|
||||||
|
let empty = title_bar_drag_handlers(
|
||||||
|
div().id("empty-space").h_full().flex_grow_1().min_w_16(),
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
.debug_selector(|| "empty-space".into())
|
||||||
|
.on_mouse_down(MouseButton::Left, {
|
||||||
|
let flags = flags.clone();
|
||||||
|
move |_, _, cx| flags.update(cx, |f, _| f.empty_down.store(true, Ordering::SeqCst))
|
||||||
|
})
|
||||||
|
.on_click({
|
||||||
|
let flags = flags.clone();
|
||||||
|
move |_, _, cx| flags.update(cx, |f, _| f.empty_click.store(true, Ordering::SeqCst))
|
||||||
|
});
|
||||||
|
|
||||||
|
let control =
|
||||||
|
title_bar_drag_handlers(div().id("control-space").h_8().flex_grow_1(), window, cx)
|
||||||
|
.debug_selector(|| "control-space".into())
|
||||||
|
.on_mouse_down(MouseButton::Left, {
|
||||||
|
let flags = flags.clone();
|
||||||
|
move |_, _, cx| {
|
||||||
|
flags.update(cx, |f, _| f.control_down.store(true, Ordering::SeqCst))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on_click({
|
||||||
|
let flags = flags.clone();
|
||||||
|
move |_, _, cx| {
|
||||||
|
flags.update(cx, |f, _| f.control_click.store(true, Ordering::SeqCst))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.child(
|
||||||
|
TabBar::new("probe-bar")
|
||||||
|
.child(Tab::new().label("Alpha"))
|
||||||
|
.child(Tab::new().label("Beta"))
|
||||||
|
.suffix(div())
|
||||||
|
.last_empty_space(empty),
|
||||||
|
)
|
||||||
|
.child(control)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diagnostic: verify that the tab bar's `last_empty_space` element
|
||||||
|
/// receives mouse events when wrapped by `title_bar_drag_handlers`.
|
||||||
|
#[gpui::test]
|
||||||
|
fn tab_bar_empty_space_receives_events(cx: &mut TestAppContext) {
|
||||||
|
let (flags, handle) = cx.update(|cx| {
|
||||||
|
cx.set_global(Theme::default());
|
||||||
|
let flags = cx.new(|_| ProbeFlags::default());
|
||||||
|
let handle = cx.open_window(
|
||||||
|
WindowOptions {
|
||||||
|
window_bounds: Some(gpui::WindowBounds::Windowed(gpui::Bounds {
|
||||||
|
origin: gpui::Point::default(),
|
||||||
|
size: size(px(800.), px(100.)),
|
||||||
|
})),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
|window, cx| {
|
||||||
|
let flags = flags.clone();
|
||||||
|
let content = cx.new(|_| ProbeView { flags });
|
||||||
|
cx.new(|cx| Root::new(content, window, cx))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(flags, handle.unwrap())
|
||||||
|
});
|
||||||
|
let mut cx = VisualTestContext::from_window(handle.into(), cx);
|
||||||
|
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
_ = window.draw(cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let empty_bounds = cx
|
||||||
|
.debug_bounds("empty-space")
|
||||||
|
.expect("empty-space must be laid out");
|
||||||
|
let control_bounds = cx
|
||||||
|
.debug_bounds("control-space")
|
||||||
|
.expect("control-space must be laid out");
|
||||||
|
let empty_center = empty_bounds.center();
|
||||||
|
let control_center = control_bounds.center();
|
||||||
|
|
||||||
|
// Control: a plain div with the same handlers, outside the TabBar.
|
||||||
|
cx.simulate_click(control_center, Default::default());
|
||||||
|
// Target: the TabBar's last_empty_space.
|
||||||
|
cx.simulate_click(empty_center, Default::default());
|
||||||
|
|
||||||
|
let (empty_down, empty_click, control_down, control_click) = cx.read(|cx| {
|
||||||
|
let flags = flags.read(cx);
|
||||||
|
(
|
||||||
|
flags.empty_down.load(Ordering::SeqCst),
|
||||||
|
flags.empty_click.load(Ordering::SeqCst),
|
||||||
|
flags.control_down.load(Ordering::SeqCst),
|
||||||
|
flags.control_click.load(Ordering::SeqCst),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
control_down,
|
||||||
|
"control mouse_down must fire at {control_center:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
control_click,
|
||||||
|
"control click must fire at {control_center:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
empty_down,
|
||||||
|
"empty-space mouse_down must fire at {empty_center:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
empty_click,
|
||||||
|
"empty-space click must fire at {empty_center:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The title-bar drag overlay must cover the dead zone between the last
|
||||||
|
/// tab and the suffix (TabBar's scrollable content is content-width, so
|
||||||
|
/// that area has no element of its own).
|
||||||
|
#[gpui::test]
|
||||||
|
fn title_bar_drag_overlay_covers_the_dead_zone(cx: &mut TestAppContext) {
|
||||||
|
let fixture = setup(cx);
|
||||||
|
let mut cx = VisualTestContext::from_window(fixture.window.into(), cx);
|
||||||
|
let (item, _tab_panel, _) = build_tabs(&fixture, &["Alpha", "Beta"], None, &mut cx);
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
fixture
|
||||||
|
.dock_area
|
||||||
|
.update(cx, |dock_area, cx| dock_area.set_center(item, window, cx));
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
// Frame 1 measures the tab bar; the overlay renders from frame 2 on.
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
_ = window.draw(cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
_ = window.draw(cx);
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
|
||||||
|
let overlay = cx
|
||||||
|
.debug_bounds("title-bar-drag")
|
||||||
|
.expect("title-bar drag overlay must be laid out");
|
||||||
|
assert!(
|
||||||
|
overlay.size.height > px(20.),
|
||||||
|
"overlay must cover the tab bar height, got {overlay:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
overlay.size.width > px(100.),
|
||||||
|
"overlay must cover the dead zone, got {overlay:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A click in the middle of the dead zone must reach the overlay
|
||||||
|
// without panicking (its handlers start a window move on drag).
|
||||||
|
cx.simulate_click(overlay.center(), Default::default());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
App, Hsla, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
|
||||||
|
StatefulInteractiveElement, Styled, Window, div, px,
|
||||||
|
};
|
||||||
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex};
|
||||||
|
|
||||||
|
/// The standard width of a window control button.
|
||||||
|
const CONTROL_WIDTH: f32 = 34.;
|
||||||
|
|
||||||
|
#[derive(IntoElement, Clone)]
|
||||||
|
enum ControlIcon {
|
||||||
|
Minimize,
|
||||||
|
Restore,
|
||||||
|
Maximize,
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlIcon {
|
||||||
|
fn minimize() -> Self {
|
||||||
|
Self::Minimize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore() -> Self {
|
||||||
|
Self::Restore
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maximize() -> Self {
|
||||||
|
Self::Maximize
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close() -> Self {
|
||||||
|
Self::Close
|
||||||
|
}
|
||||||
|
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Minimize => "minimize",
|
||||||
|
Self::Restore => "restore",
|
||||||
|
Self::Maximize => "maximize",
|
||||||
|
Self::Close => "close",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn icon(&self) -> IconName {
|
||||||
|
match self {
|
||||||
|
Self::Minimize => IconName::WindowMinimize,
|
||||||
|
Self::Restore => IconName::WindowRestore,
|
||||||
|
Self::Maximize => IconName::WindowMaximize,
|
||||||
|
Self::Close => IconName::WindowClose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_control_area(&self) -> gpui::WindowControlArea {
|
||||||
|
match self {
|
||||||
|
Self::Minimize => gpui::WindowControlArea::Min,
|
||||||
|
Self::Restore | Self::Maximize => gpui::WindowControlArea::Max,
|
||||||
|
Self::Close => gpui::WindowControlArea::Close,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_close(&self) -> bool {
|
||||||
|
matches!(self, Self::Close)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn hover_fg(&self, cx: &App) -> Hsla {
|
||||||
|
if self.is_close() {
|
||||||
|
cx.theme().danger_foreground
|
||||||
|
} else {
|
||||||
|
cx.theme().secondary_foreground
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn hover_bg(&self, cx: &App) -> Hsla {
|
||||||
|
if self.is_close() {
|
||||||
|
cx.theme().danger
|
||||||
|
} else {
|
||||||
|
cx.theme().secondary_hover
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn active_bg(&self, cx: &mut App) -> Hsla {
|
||||||
|
if self.is_close() {
|
||||||
|
cx.theme().danger_active
|
||||||
|
} else {
|
||||||
|
cx.theme().secondary_active
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderOnce for ControlIcon {
|
||||||
|
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
let is_linux = cfg!(target_os = "linux");
|
||||||
|
let is_windows = cfg!(target_os = "windows");
|
||||||
|
let hover_fg = self.hover_fg(cx);
|
||||||
|
let hover_bg = self.hover_bg(cx);
|
||||||
|
let active_bg = self.active_bg(cx);
|
||||||
|
let icon = self.clone();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(self.id())
|
||||||
|
.flex()
|
||||||
|
.w(px(CONTROL_WIDTH))
|
||||||
|
.h_full()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.justify_center()
|
||||||
|
.content_center()
|
||||||
|
.items_center()
|
||||||
|
.text_color(cx.theme().foreground)
|
||||||
|
.hover(|style| style.bg(hover_bg).text_color(hover_fg))
|
||||||
|
.active(|style| style.bg(active_bg).text_color(hover_fg))
|
||||||
|
.when(is_windows, |this| {
|
||||||
|
this.window_control_area(self.window_control_area())
|
||||||
|
})
|
||||||
|
.when(is_linux, |this| {
|
||||||
|
this.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||||
|
window.prevent_default();
|
||||||
|
cx.stop_propagation();
|
||||||
|
})
|
||||||
|
.on_click(move |_, window, cx| {
|
||||||
|
cx.stop_propagation();
|
||||||
|
match icon {
|
||||||
|
Self::Minimize => window.minimize_window(),
|
||||||
|
Self::Restore | Self::Maximize => window.zoom_window(),
|
||||||
|
Self::Close => window.remove_window(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(Icon::new(self.icon()).small())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the window control buttons, or an empty placeholder on macOS (the
|
||||||
|
/// traffic lights are drawn natively).
|
||||||
|
pub(crate) fn window_controls(window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
|
if cfg!(target_os = "macos") || cfg!(target_family = "wasm") {
|
||||||
|
return div().id("window-controls");
|
||||||
|
}
|
||||||
|
|
||||||
|
let supported = window.window_controls();
|
||||||
|
|
||||||
|
h_flex()
|
||||||
|
.id("window-controls")
|
||||||
|
.items_center()
|
||||||
|
.flex_shrink_0()
|
||||||
|
.h_full()
|
||||||
|
.border_l_1()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(cx.theme().border)
|
||||||
|
.bg(cx.theme().tokens.tab_bar)
|
||||||
|
.when(supported.minimize, |this| {
|
||||||
|
this.child(ControlIcon::minimize())
|
||||||
|
})
|
||||||
|
.when(supported.maximize, |this| {
|
||||||
|
this.child(if window.is_maximized() {
|
||||||
|
ControlIcon::restore()
|
||||||
|
} else {
|
||||||
|
ControlIcon::maximize()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.child(ControlIcon::close())
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
|
use dock::{DockArea, DockPlacement, Panel, PanelEvent, title_bar_drag_handlers};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
|
App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
|
||||||
SharedString, StyleRefinement, Subscription, WeakEntity, Window, div,
|
SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, px,
|
||||||
};
|
};
|
||||||
use gpui_component::avatar::Avatar;
|
use gpui_component::avatar::Avatar;
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use dock::{DockArea, DockPlacement, Panel, PanelEvent};
|
|
||||||
use gpui_component::input::InputState;
|
use gpui_component::input::InputState;
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use signed_state::{Backend, BackendEvent, ProfileStore};
|
use signed_state::{Backend, BackendEvent, ProfileStore};
|
||||||
@@ -129,7 +129,7 @@ impl Focusable for SidebarPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Render for SidebarPanel {
|
impl Render for SidebarPanel {
|
||||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
if !self.logged_in {
|
if !self.logged_in {
|
||||||
return v_flex()
|
return v_flex()
|
||||||
.p_4()
|
.p_4()
|
||||||
@@ -183,11 +183,16 @@ impl Render for SidebarPanel {
|
|||||||
let name = profile.name();
|
let name = profile.name();
|
||||||
let picture = profile.picture();
|
let picture = profile.picture();
|
||||||
|
|
||||||
this.child(
|
this.child(title_bar_drag_handlers(
|
||||||
h_flex()
|
h_flex()
|
||||||
|
.id("sidebar-profile")
|
||||||
.h_12()
|
.h_12()
|
||||||
.px_3()
|
.px_3()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
|
// The macOS traffic lights overlay the
|
||||||
|
// top-left corner of the window; keep the
|
||||||
|
// profile content clear of them.
|
||||||
|
.when(cfg!(target_os = "macos"), |this| this.pl(px(80.)))
|
||||||
.child(
|
.child(
|
||||||
Avatar::new()
|
Avatar::new()
|
||||||
.name(name.clone())
|
.name(name.clone())
|
||||||
@@ -196,7 +201,9 @@ impl Render for SidebarPanel {
|
|||||||
.border_0(),
|
.border_0(),
|
||||||
)
|
)
|
||||||
.child(div().text_sm().child(name)),
|
.child(div().text_sm().child(name)),
|
||||||
)
|
window,
|
||||||
|
cx,
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.child(
|
.child(
|
||||||
v_flex()
|
v_flex()
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use assets::CustomIconName;
|
|
||||||
use dock::{DockArea, DockItem};
|
use dock::{DockArea, DockItem};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px};
|
use gpui::{Context, Entity, Render, Subscription, Window, div, px};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::{Root, StyledExt, Theme};
|
||||||
use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex};
|
|
||||||
use signed_state::{Backend, BackendEvent};
|
use signed_state::{Backend, BackendEvent};
|
||||||
|
|
||||||
use crate::views::SidebarPanel;
|
use crate::views::SidebarPanel;
|
||||||
use crate::views::sidebar::passphrase_dialog;
|
use crate::views::sidebar::passphrase_dialog;
|
||||||
|
|
||||||
/// Root view of the app: title bar, dock area, status bar.
|
/// Root view of the app: dock area (whose center tab bar doubles as the
|
||||||
|
/// window title bar), overlays.
|
||||||
pub struct Workspace {
|
pub struct Workspace {
|
||||||
dock: Entity<DockArea>,
|
dock: Entity<DockArea>,
|
||||||
status: SharedString,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
_passphrase_subscription: Subscription,
|
_passphrase_subscription: Subscription,
|
||||||
}
|
}
|
||||||
@@ -40,16 +38,6 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let connected = backend.read(cx).is_connected();
|
|
||||||
let sync_progress = backend.read(cx).sync_progress();
|
|
||||||
|
|
||||||
let status = if let Some((total, current)) = sync_progress {
|
|
||||||
format!("Syncing repositories... {current}/{total}").into()
|
|
||||||
} else if connected {
|
|
||||||
"Connected".into()
|
|
||||||
} else {
|
|
||||||
"Connecting...".into()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut subscriptions = vec![];
|
let mut subscriptions = vec![];
|
||||||
|
|
||||||
@@ -57,19 +45,6 @@ impl Workspace {
|
|||||||
Theme::sync_system_appearance(Some(window), cx);
|
Theme::sync_system_appearance(Some(window), cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
|
||||||
match event {
|
|
||||||
BackendEvent::SyncProgress { total, current } => {
|
|
||||||
this.status = format!("Syncing repositories... {current}/{total}").into()
|
|
||||||
}
|
|
||||||
BackendEvent::Synced => this.status = "Connected".into(),
|
|
||||||
BackendEvent::Connected => this.status = "Connected".into(),
|
|
||||||
BackendEvent::Error(error) => this.status = error.clone().into(),
|
|
||||||
_ => return,
|
|
||||||
}
|
|
||||||
cx.notify();
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Ask for the passphrase when the stored identity is NIP-49
|
// Ask for the passphrase when the stored identity is NIP-49
|
||||||
// encrypted. Subscribed via the window, since opening a dialog
|
// encrypted. Subscribed via the window, since opening a dialog
|
||||||
// needs one.
|
// needs one.
|
||||||
@@ -98,7 +73,6 @@ impl Workspace {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
status,
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
_passphrase_subscription: passphrase_subscription,
|
_passphrase_subscription: passphrase_subscription,
|
||||||
}
|
}
|
||||||
@@ -111,44 +85,11 @@ impl Render for Workspace {
|
|||||||
let notification_layer = Root::render_notification_layer(window, cx);
|
let notification_layer = Root::render_notification_layer(window, cx);
|
||||||
|
|
||||||
div()
|
div()
|
||||||
// All `img` elements below (avatars, …) load through the shared
|
|
||||||
// bounded LRU cache instead of the window-global asset cache,
|
|
||||||
// so images can be freed when views close or when the cache is
|
|
||||||
// full.
|
|
||||||
.image_cache(crate::image_cache::global(cx))
|
.image_cache(crate::image_cache::global(cx))
|
||||||
.id("workspace")
|
.id("workspace")
|
||||||
.v_flex()
|
.v_flex()
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.child(self.dock.clone())
|
||||||
v_flex()
|
|
||||||
.size_full()
|
|
||||||
// Title Bar
|
|
||||||
.child(
|
|
||||||
TitleBar::new()
|
|
||||||
// Left
|
|
||||||
.child(div())
|
|
||||||
// Right
|
|
||||||
.child(h_flex().px_2().map(|this| {
|
|
||||||
if self.status == "Connected" {
|
|
||||||
this.child(
|
|
||||||
Button::new("relay")
|
|
||||||
.icon(CustomIconName::GlobalOn)
|
|
||||||
.small()
|
|
||||||
.ghost(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(cx.theme().muted_foreground)
|
|
||||||
.child(self.status.clone()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
// Dock Area
|
|
||||||
.child(self.dock.clone()),
|
|
||||||
)
|
|
||||||
// Notifications
|
// Notifications
|
||||||
.children(notification_layer)
|
.children(notification_layer)
|
||||||
// Modals
|
// Modals
|
||||||
|
|||||||
+5
-2
@@ -38,10 +38,12 @@ fn main() {
|
|||||||
std::fs::create_dir_all(paths::repos_dir()).ok();
|
std::fs::create_dir_all(paths::repos_dir()).ok();
|
||||||
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
|
signed_state::GitStore::set_global(paths::repos_dir().clone(), cx);
|
||||||
|
|
||||||
// Set up the window bounds
|
// Set up the window options
|
||||||
let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx);
|
let bounds = Bounds::centered(None, size(px(1120.0), px(750.0)), cx);
|
||||||
|
|
||||||
// Set up the window options
|
// The dock's tab bar acts as the window title bar: the app owns
|
||||||
|
// title-bar dragging (via `start_window_move` on the tab bar), so
|
||||||
|
// AppKit must not treat the top strip as a native drag region.
|
||||||
let opts = WindowOptions {
|
let opts = WindowOptions {
|
||||||
window_background: WindowBackgroundAppearance::Opaque,
|
window_background: WindowBackgroundAppearance::Opaque,
|
||||||
window_decorations: Some(WindowDecorations::Client),
|
window_decorations: Some(WindowDecorations::Client),
|
||||||
@@ -54,6 +56,7 @@ fn main() {
|
|||||||
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
||||||
appears_transparent: true,
|
appears_transparent: true,
|
||||||
}),
|
}),
|
||||||
|
app_owns_titlebar_drag: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user