Files
oak-gpui/crates/workspace/src/dock.rs
T
Thorsten BallandPiotr e65a76f0ec Add ability to navigate to/from docks via keybindings (#7141)
This adds the ability to navigate to/from docks (Terminal, Project,
Collaboration, Assistant) via keybindings.

When using the `ActivatePaneInDirection` keybinding from the
left/bottom/right dock, we check whether the movement is towards the
center panel. If it is, we focus the last active pane.

Fixes https://github.com/zed-industries/zed/issues/6833 and it came up
in a few other tickes/discussions.

Release Notes:

- Added ability to navigate to docks and back to the editor using the
`workspace::ActivatePaneInDirection` action (by default bound to `Ctrl-w
[hjkl]` in Vim mode).
([#6833](https://github.com/zed-industries/zed/issues/6833)).

## Drawback

There's this weird behavior: if you start Zed and no files are opened,
you focus terminal, go left (project panel), then back to right to
terminal, the terminal isn't focused. Even though we focus it in the
code.

Maybe this is a bug in the current focus handling code?

## Demo


https://github.com/zed-industries/zed/assets/1185253/5d56db40-36aa-4758-a3bc-7a0de20ce5d7

---------

Co-authored-by: Piotr <piotr@zed.dev>
2024-02-01 12:18:12 +01:00

827 lines
28 KiB
Rust

use crate::persistence::model::DockData;
use crate::DraggedDock;
use crate::{status_bar::StatusItemView, Workspace};
use gpui::{
div, px, Action, AnchorCorner, AnyView, AppContext, Axis, ClickEvent, Entity, EntityId,
EventEmitter, FocusHandle, FocusableView, IntoElement, KeyContext, MouseButton, ParentElement,
Render, SharedString, Styled, Subscription, View, ViewContext, VisualContext, WeakView,
WindowContext,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::SettingsStore;
use std::sync::Arc;
use ui::{h_flex, ContextMenu, IconButton, Tooltip};
use ui::{prelude::*, right_click_menu};
const RESIZE_HANDLE_SIZE: Pixels = Pixels(6.);
pub enum PanelEvent {
ZoomIn,
ZoomOut,
Activate,
Close,
}
pub trait Panel: FocusableView + EventEmitter<PanelEvent> {
fn persistent_name() -> &'static str;
fn position(&self, cx: &WindowContext) -> DockPosition;
fn position_is_valid(&self, position: DockPosition) -> bool;
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>);
fn size(&self, cx: &WindowContext) -> Pixels;
fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>);
fn icon(&self, cx: &WindowContext) -> Option<ui::IconName>;
fn icon_tooltip(&self, cx: &WindowContext) -> Option<&'static str>;
fn toggle_action(&self) -> Box<dyn Action>;
fn icon_label(&self, _: &WindowContext) -> Option<String> {
None
}
fn is_zoomed(&self, _cx: &WindowContext) -> bool {
false
}
fn starts_open(&self, _cx: &WindowContext) -> bool {
false
}
fn set_zoomed(&mut self, _zoomed: bool, _cx: &mut ViewContext<Self>) {}
fn set_active(&mut self, _active: bool, _cx: &mut ViewContext<Self>) {}
}
pub trait PanelHandle: Send + Sync {
fn panel_id(&self) -> EntityId;
fn persistent_name(&self) -> &'static str;
fn position(&self, cx: &WindowContext) -> DockPosition;
fn position_is_valid(&self, position: DockPosition, cx: &WindowContext) -> bool;
fn set_position(&self, position: DockPosition, cx: &mut WindowContext);
fn is_zoomed(&self, cx: &WindowContext) -> bool;
fn set_zoomed(&self, zoomed: bool, cx: &mut WindowContext);
fn set_active(&self, active: bool, cx: &mut WindowContext);
fn size(&self, cx: &WindowContext) -> Pixels;
fn set_size(&self, size: Option<Pixels>, cx: &mut WindowContext);
fn icon(&self, cx: &WindowContext) -> Option<ui::IconName>;
fn icon_tooltip(&self, cx: &WindowContext) -> Option<&'static str>;
fn toggle_action(&self, cx: &WindowContext) -> Box<dyn Action>;
fn icon_label(&self, cx: &WindowContext) -> Option<String>;
fn focus_handle(&self, cx: &AppContext) -> FocusHandle;
fn to_any(&self) -> AnyView;
}
impl<T> PanelHandle for View<T>
where
T: Panel,
{
fn panel_id(&self) -> EntityId {
Entity::entity_id(self)
}
fn persistent_name(&self) -> &'static str {
T::persistent_name()
}
fn position(&self, cx: &WindowContext) -> DockPosition {
self.read(cx).position(cx)
}
fn position_is_valid(&self, position: DockPosition, cx: &WindowContext) -> bool {
self.read(cx).position_is_valid(position)
}
fn set_position(&self, position: DockPosition, cx: &mut WindowContext) {
self.update(cx, |this, cx| this.set_position(position, cx))
}
fn is_zoomed(&self, cx: &WindowContext) -> bool {
self.read(cx).is_zoomed(cx)
}
fn set_zoomed(&self, zoomed: bool, cx: &mut WindowContext) {
self.update(cx, |this, cx| this.set_zoomed(zoomed, cx))
}
fn set_active(&self, active: bool, cx: &mut WindowContext) {
self.update(cx, |this, cx| this.set_active(active, cx))
}
fn size(&self, cx: &WindowContext) -> Pixels {
self.read(cx).size(cx)
}
fn set_size(&self, size: Option<Pixels>, cx: &mut WindowContext) {
self.update(cx, |this, cx| this.set_size(size, cx))
}
fn icon(&self, cx: &WindowContext) -> Option<ui::IconName> {
self.read(cx).icon(cx)
}
fn icon_tooltip(&self, cx: &WindowContext) -> Option<&'static str> {
self.read(cx).icon_tooltip(cx)
}
fn toggle_action(&self, cx: &WindowContext) -> Box<dyn Action> {
self.read(cx).toggle_action()
}
fn icon_label(&self, cx: &WindowContext) -> Option<String> {
self.read(cx).icon_label(cx)
}
fn to_any(&self) -> AnyView {
self.clone().into()
}
fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
self.read(cx).focus_handle(cx).clone()
}
}
impl From<&dyn PanelHandle> for AnyView {
fn from(val: &dyn PanelHandle) -> Self {
val.to_any()
}
}
pub struct Dock {
position: DockPosition,
panel_entries: Vec<PanelEntry>,
is_open: bool,
active_panel_index: usize,
focus_handle: FocusHandle,
pub(crate) serialized_dock: Option<DockData>,
_focus_subscription: Subscription,
}
impl FocusableView for Dock {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DockPosition {
Left,
Bottom,
Right,
}
impl DockPosition {
fn to_label(&self) -> &'static str {
match self {
Self::Left => "left",
Self::Bottom => "bottom",
Self::Right => "right",
}
}
pub fn axis(&self) -> Axis {
match self {
Self::Left | Self::Right => Axis::Horizontal,
Self::Bottom => Axis::Vertical,
}
}
}
struct PanelEntry {
panel: Arc<dyn PanelHandle>,
_subscriptions: [Subscription; 3],
}
pub struct PanelButtons {
dock: View<Dock>,
}
impl Dock {
pub fn new(position: DockPosition, cx: &mut ViewContext<Workspace>) -> View<Self> {
let focus_handle = cx.focus_handle();
let dock = cx.new_view(|cx: &mut ViewContext<Self>| {
let focus_subscription = cx.on_focus(&focus_handle, |dock, cx| {
if let Some(active_entry) = dock.panel_entries.get(dock.active_panel_index) {
active_entry.panel.focus_handle(cx).focus(cx)
}
});
Self {
position,
panel_entries: Default::default(),
active_panel_index: 0,
is_open: false,
focus_handle: focus_handle.clone(),
_focus_subscription: focus_subscription,
serialized_dock: None,
}
});
cx.on_focus_in(&focus_handle, {
let dock = dock.downgrade();
move |workspace, cx| {
let Some(dock) = dock.upgrade() else {
return;
};
let Some(panel) = dock.read(cx).active_panel() else {
return;
};
if panel.is_zoomed(cx) {
workspace.zoomed = Some(panel.to_any().downgrade().into());
workspace.zoomed_position = Some(position);
} else {
workspace.zoomed = None;
workspace.zoomed_position = None;
}
workspace.dismiss_zoomed_items_to_reveal(Some(position), cx);
workspace.update_active_view_for_followers(cx)
}
})
.detach();
cx.observe(&dock, move |workspace, dock, cx| {
if dock.read(cx).is_open() {
if let Some(panel) = dock.read(cx).active_panel() {
if panel.is_zoomed(cx) {
workspace.zoomed = Some(panel.to_any().downgrade());
workspace.zoomed_position = Some(position);
return;
}
}
}
if workspace.zoomed_position == Some(position) {
workspace.zoomed = None;
workspace.zoomed_position = None;
}
})
.detach();
dock
}
pub fn position(&self) -> DockPosition {
self.position
}
pub fn is_open(&self) -> bool {
self.is_open
}
pub fn panel<T: Panel>(&self) -> Option<View<T>> {
self.panel_entries
.iter()
.find_map(|entry| entry.panel.to_any().clone().downcast().ok())
}
pub fn panel_index_for_type<T: Panel>(&self) -> Option<usize> {
self.panel_entries
.iter()
.position(|entry| entry.panel.to_any().downcast::<T>().is_ok())
}
pub fn panel_index_for_persistent_name(
&self,
ui_name: &str,
_cx: &AppContext,
) -> Option<usize> {
self.panel_entries
.iter()
.position(|entry| entry.panel.persistent_name() == ui_name)
}
pub fn active_panel_index(&self) -> usize {
self.active_panel_index
}
pub(crate) fn set_open(&mut self, open: bool, cx: &mut ViewContext<Self>) {
if open != self.is_open {
self.is_open = open;
if let Some(active_panel) = self.panel_entries.get(self.active_panel_index) {
active_panel.panel.set_active(open, cx);
}
cx.notify();
}
}
pub fn set_panel_zoomed(&mut self, panel: &AnyView, zoomed: bool, cx: &mut ViewContext<Self>) {
for entry in &mut self.panel_entries {
if entry.panel.panel_id() == panel.entity_id() {
if zoomed != entry.panel.is_zoomed(cx) {
entry.panel.set_zoomed(zoomed, cx);
}
} else if entry.panel.is_zoomed(cx) {
entry.panel.set_zoomed(false, cx);
}
}
cx.notify();
}
pub fn zoom_out(&mut self, cx: &mut ViewContext<Self>) {
for entry in &mut self.panel_entries {
if entry.panel.is_zoomed(cx) {
entry.panel.set_zoomed(false, cx);
}
}
}
pub(crate) fn add_panel<T: Panel>(
&mut self,
panel: View<T>,
workspace: WeakView<Workspace>,
cx: &mut ViewContext<Self>,
) {
let subscriptions = [
cx.observe(&panel, |_, _, cx| cx.notify()),
cx.observe_global::<SettingsStore>({
let workspace = workspace.clone();
let panel = panel.clone();
move |this, cx| {
let new_position = panel.read(cx).position(cx);
if new_position == this.position {
return;
}
let Ok(new_dock) = workspace.update(cx, |workspace, cx| {
if panel.is_zoomed(cx) {
workspace.zoomed_position = Some(new_position);
}
match new_position {
DockPosition::Left => &workspace.left_dock,
DockPosition::Bottom => &workspace.bottom_dock,
DockPosition::Right => &workspace.right_dock,
}
.clone()
}) else {
return;
};
let was_visible = this.is_open()
&& this.visible_panel().map_or(false, |active_panel| {
active_panel.panel_id() == Entity::entity_id(&panel)
});
this.remove_panel(&panel, cx);
new_dock.update(cx, |new_dock, cx| {
new_dock.add_panel(panel.clone(), workspace.clone(), cx);
if was_visible {
new_dock.set_open(true, cx);
new_dock.activate_panel(new_dock.panels_len() - 1, cx);
}
});
}
}),
cx.subscribe(&panel, move |this, panel, event, cx| match event {
PanelEvent::ZoomIn => {
this.set_panel_zoomed(&panel.to_any(), true, cx);
if !panel.focus_handle(cx).contains_focused(cx) {
cx.focus_view(&panel);
}
workspace
.update(cx, |workspace, cx| {
workspace.zoomed = Some(panel.downgrade().into());
workspace.zoomed_position = Some(panel.read(cx).position(cx));
})
.ok();
}
PanelEvent::ZoomOut => {
this.set_panel_zoomed(&panel.to_any(), false, cx);
workspace
.update(cx, |workspace, cx| {
if workspace.zoomed_position == Some(this.position) {
workspace.zoomed = None;
workspace.zoomed_position = None;
}
cx.notify();
})
.ok();
}
PanelEvent::Activate => {
if let Some(ix) = this
.panel_entries
.iter()
.position(|entry| entry.panel.panel_id() == Entity::entity_id(&panel))
{
this.set_open(true, cx);
this.activate_panel(ix, cx);
cx.focus_view(&panel);
}
}
PanelEvent::Close => {
if this
.visible_panel()
.map_or(false, |p| p.panel_id() == Entity::entity_id(&panel))
{
this.set_open(false, cx);
}
}
}),
];
let name = panel.persistent_name().to_string();
self.panel_entries.push(PanelEntry {
panel: Arc::new(panel.clone()),
_subscriptions: subscriptions,
});
if let Some(serialized) = self.serialized_dock.clone() {
if serialized.active_panel == Some(name) {
self.activate_panel(self.panel_entries.len() - 1, cx);
if serialized.visible {
self.set_open(true, cx);
}
if serialized.zoom {
if let Some(panel) = self.active_panel() {
panel.set_zoomed(true, cx)
};
}
}
} else if panel.read(cx).starts_open(cx) {
self.activate_panel(self.panel_entries.len() - 1, cx);
self.set_open(true, cx);
}
cx.notify()
}
pub fn remove_panel<T: Panel>(&mut self, panel: &View<T>, cx: &mut ViewContext<Self>) {
if let Some(panel_ix) = self
.panel_entries
.iter()
.position(|entry| entry.panel.panel_id() == Entity::entity_id(panel))
{
if panel_ix == self.active_panel_index {
self.active_panel_index = 0;
self.set_open(false, cx);
} else if panel_ix < self.active_panel_index {
self.active_panel_index -= 1;
}
self.panel_entries.remove(panel_ix);
cx.notify();
}
}
pub fn panels_len(&self) -> usize {
self.panel_entries.len()
}
pub fn activate_panel(&mut self, panel_ix: usize, cx: &mut ViewContext<Self>) {
if panel_ix != self.active_panel_index {
if let Some(active_panel) = self.panel_entries.get(self.active_panel_index) {
active_panel.panel.set_active(false, cx);
}
self.active_panel_index = panel_ix;
if let Some(active_panel) = self.panel_entries.get(self.active_panel_index) {
active_panel.panel.set_active(true, cx);
}
cx.notify();
}
}
pub fn visible_panel(&self) -> Option<&Arc<dyn PanelHandle>> {
let entry = self.visible_entry()?;
Some(&entry.panel)
}
pub fn active_panel(&self) -> Option<&Arc<dyn PanelHandle>> {
Some(&self.panel_entries.get(self.active_panel_index)?.panel)
}
fn visible_entry(&self) -> Option<&PanelEntry> {
if self.is_open {
self.panel_entries.get(self.active_panel_index)
} else {
None
}
}
pub fn zoomed_panel(&self, cx: &WindowContext) -> Option<Arc<dyn PanelHandle>> {
let entry = self.visible_entry()?;
if entry.panel.is_zoomed(cx) {
Some(entry.panel.clone())
} else {
None
}
}
pub fn panel_size(&self, panel: &dyn PanelHandle, cx: &WindowContext) -> Option<Pixels> {
self.panel_entries
.iter()
.find(|entry| entry.panel.panel_id() == panel.panel_id())
.map(|entry| entry.panel.size(cx))
}
pub fn active_panel_size(&self, cx: &WindowContext) -> Option<Pixels> {
if self.is_open {
self.panel_entries
.get(self.active_panel_index)
.map(|entry| entry.panel.size(cx))
} else {
None
}
}
pub fn resize_active_panel(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
if let Some(entry) = self.panel_entries.get_mut(self.active_panel_index) {
let size = size.map(|size| size.max(RESIZE_HANDLE_SIZE));
entry.panel.set_size(size, cx);
cx.notify();
}
}
pub fn toggle_action(&self) -> Box<dyn Action> {
match self.position {
DockPosition::Left => crate::ToggleLeftDock.boxed_clone(),
DockPosition::Bottom => crate::ToggleBottomDock.boxed_clone(),
DockPosition::Right => crate::ToggleRightDock.boxed_clone(),
}
}
fn dispatch_context() -> KeyContext {
let mut dispatch_context = KeyContext::default();
dispatch_context.add("Dock");
dispatch_context
}
}
impl Render for Dock {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let dispatch_context = Self::dispatch_context();
if let Some(entry) = self.visible_entry() {
let size = entry.panel.size(cx);
let position = self.position;
let mut handle = div()
.id("resize-handle")
.on_drag(DraggedDock(position), |dock, cx| {
cx.stop_propagation();
cx.new_view(|_| dock.clone())
})
.on_click(cx.listener(|v, e: &ClickEvent, cx| {
if e.down.button == MouseButton::Left && e.down.click_count == 2 {
v.resize_active_panel(None, cx);
cx.stop_propagation();
}
}))
.z_index(1)
.block_mouse();
match self.position() {
DockPosition::Left => {
handle = handle
.absolute()
.right(px(0.))
.top(px(0.))
.h_full()
.w(RESIZE_HANDLE_SIZE)
.cursor_col_resize();
}
DockPosition::Bottom => {
handle = handle
.absolute()
.top(px(0.))
.left(px(0.))
.w_full()
.h(RESIZE_HANDLE_SIZE)
.cursor_row_resize();
}
DockPosition::Right => {
handle = handle
.absolute()
.top(px(0.))
.left(px(0.))
.h_full()
.w(RESIZE_HANDLE_SIZE)
.cursor_col_resize();
}
}
div()
.key_context(dispatch_context)
.track_focus(&self.focus_handle)
.flex()
.bg(cx.theme().colors().panel_background)
.border_color(cx.theme().colors().border)
.overflow_hidden()
.map(|this| match self.position().axis() {
Axis::Horizontal => this.w(size).h_full().flex_row(),
Axis::Vertical => this.h(size).w_full().flex_col(),
})
.map(|this| match self.position() {
DockPosition::Left => this.border_r(),
DockPosition::Right => this.border_l(),
DockPosition::Bottom => this.border_t(),
})
.child(
div()
.map(|this| match self.position().axis() {
Axis::Horizontal => this.min_w(size).h_full(),
Axis::Vertical => this.min_h(size).w_full(),
})
.child(entry.panel.to_any().cached()),
)
.child(handle)
} else {
div()
.key_context(dispatch_context)
.track_focus(&self.focus_handle)
}
}
}
impl PanelButtons {
pub fn new(dock: View<Dock>, cx: &mut ViewContext<Self>) -> Self {
cx.observe(&dock, |_, _, cx| cx.notify()).detach();
Self { dock }
}
}
impl Render for PanelButtons {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let dock = self.dock.read(cx);
let active_index = dock.active_panel_index;
let is_open = dock.is_open;
let dock_position = dock.position;
let (menu_anchor, menu_attach) = match dock.position {
DockPosition::Left => (AnchorCorner::BottomLeft, AnchorCorner::TopLeft),
DockPosition::Bottom | DockPosition::Right => {
(AnchorCorner::BottomRight, AnchorCorner::TopRight)
}
};
let buttons = dock
.panel_entries
.iter()
.enumerate()
.filter_map(|(i, entry)| {
let icon = entry.panel.icon(cx)?;
let icon_tooltip = entry.panel.icon_tooltip(cx)?;
let name = entry.panel.persistent_name();
let panel = entry.panel.clone();
let is_active_button = i == active_index && is_open;
let (action, tooltip) = if is_active_button {
let action = dock.toggle_action();
let tooltip: SharedString =
format!("Close {} dock", dock.position.to_label()).into();
(action, tooltip)
} else {
let action = entry.panel.toggle_action(cx);
(action, icon_tooltip.into())
};
Some(
right_click_menu(name)
.menu(move |cx| {
const POSITIONS: [DockPosition; 3] = [
DockPosition::Left,
DockPosition::Right,
DockPosition::Bottom,
];
ContextMenu::build(cx, |mut menu, cx| {
for position in POSITIONS {
if position != dock_position
&& panel.position_is_valid(position, cx)
{
let panel = panel.clone();
menu = menu.entry(
format!("Dock {}", position.to_label()),
None,
move |cx| {
panel.set_position(position, cx);
},
)
}
}
menu
})
})
.anchor(menu_anchor)
.attach(menu_attach)
.trigger(
IconButton::new(name, icon)
.icon_size(IconSize::Small)
.selected(is_active_button)
.on_click({
let action = action.boxed_clone();
move |_, cx| cx.dispatch_action(action.boxed_clone())
})
.tooltip(move |cx| {
Tooltip::for_action(tooltip.clone(), &*action, cx)
}),
),
)
});
h_flex().gap_0p5().children(buttons)
}
}
impl StatusItemView for PanelButtons {
fn set_active_pane_item(
&mut self,
_active_pane_item: Option<&dyn crate::ItemHandle>,
_cx: &mut ViewContext<Self>,
) {
// Nothing to do, panel buttons don't depend on the active center item
}
}
#[cfg(any(test, feature = "test-support"))]
pub mod test {
use super::*;
use gpui::{actions, div, ViewContext, WindowContext};
pub struct TestPanel {
pub position: DockPosition,
pub zoomed: bool,
pub active: bool,
pub focus_handle: FocusHandle,
pub size: Pixels,
}
actions!(test, [ToggleTestPanel]);
impl EventEmitter<PanelEvent> for TestPanel {}
impl TestPanel {
pub fn new(position: DockPosition, cx: &mut WindowContext) -> Self {
Self {
position,
zoomed: false,
active: false,
focus_handle: cx.focus_handle(),
size: px(300.),
}
}
}
impl Render for TestPanel {
fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
div().id("test").track_focus(&self.focus_handle)
}
}
impl Panel for TestPanel {
fn persistent_name() -> &'static str {
"TestPanel"
}
fn position(&self, _: &gpui::WindowContext) -> super::DockPosition {
self.position
}
fn position_is_valid(&self, _: super::DockPosition) -> bool {
true
}
fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
self.position = position;
cx.update_global::<SettingsStore, _>(|_, _| {});
}
fn size(&self, _: &WindowContext) -> Pixels {
self.size
}
fn set_size(&mut self, size: Option<Pixels>, _: &mut ViewContext<Self>) {
self.size = size.unwrap_or(px(300.));
}
fn icon(&self, _: &WindowContext) -> Option<ui::IconName> {
None
}
fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
None
}
fn toggle_action(&self) -> Box<dyn Action> {
ToggleTestPanel.boxed_clone()
}
fn is_zoomed(&self, _: &WindowContext) -> bool {
self.zoomed
}
fn set_zoomed(&mut self, zoomed: bool, _cx: &mut ViewContext<Self>) {
self.zoomed = zoomed;
}
fn set_active(&mut self, active: bool, _cx: &mut ViewContext<Self>) {
self.active = active;
}
}
impl FocusableView for TestPanel {
fn focus_handle(&self, _cx: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
}