Add centered layout support (#9754)

Andrew Lygin created

This PR implements the Centered Layout feature (#4685):
- Added the `toggle centered layout` action.
- The centered layout mode only takes effect when there's a single
central pane.
- The state of the centered layout toggle is saved / restored between
Zed restarts.
- The paddings are controlled by the `centered_layout` setting:

```json
"centered_layout": {
  "left_padding": 0.2,
  "right_padding": 0.2
}
```

This allows us to support both the VSCode-style (equal paddings) and
IntelliJ-style (only left padding in Zen mode).

Release Notes:

- Added support for Centered Layout
([#4685](https://github.com/zed-industries/zed/pull/9754)).


https://github.com/zed-industries/zed/assets/2101250/2d5b2a16-c248-48b5-9e8c-6f1219619398

Related Issues:

- Part of #4382

Change summary

assets/settings/default.json               | 11 ++
crates/workspace/src/persistence.rs        | 25 ++++++
crates/workspace/src/persistence/model.rs  |  1 
crates/workspace/src/workspace.rs          | 90 ++++++++++++++++++++----
crates/workspace/src/workspace_settings.rs | 18 ++++
docs/src/configuring_zed.md                | 18 ++++
6 files changed, 146 insertions(+), 17 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -47,11 +47,20 @@
   // The factor to grow the active pane by. Defaults to 1.0
   // which gives the same size as all other panes.
   "active_pane_magnification": 1.0,
+  // Centered layout related settings.
+  "centered_layout": {
+    // The relative width of the left padding of the central pane from the
+    // workspace when the centered layout is used.
+    "left_padding": 0.2,
+    // The relative width of the right padding of the central pane from the
+    // workspace when the centered layout is used.
+    "right_padding": 0.2
+  },
   // The key to use for adding multiple cursors
   // Currently "alt" or "cmd_or_ctrl"  (also aliased as
   // "cmd" and "ctrl") are supported.
   "multi_cursor_modifier": "alt",
-  // Whether to enable vim modes and key bindings
+  // Whether to enable vim modes and key bindings.
   "vim_mode": false,
   // Whether to show the informational hover box when moving the mouse
   // over symbols in the editor.

crates/workspace/src/persistence.rs 🔗

@@ -138,6 +138,7 @@ define_connection! {
     //   window_height: Option<f32>, // WindowBounds::Fixed RectF height
     //   display: Option<Uuid>, // Display id
     //   fullscreen: Option<bool>, // Is the window fullscreen?
+    //   centered_layout: Option<bool>, // Is the Centered Layout mode activated?
     // )
     //
     // pane_groups(
@@ -280,6 +281,10 @@ define_connection! {
     sql!(
         ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool
     ),
+    // Add centered_layout field to workspace
+    sql!(
+        ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool
+    ),
     // Add preview field to items
     sql!(
         ALTER TABLE items ADD COLUMN preview INTEGER; //bool
@@ -299,12 +304,13 @@ impl WorkspaceDb {
 
         // Note that we re-assign the workspace_id here in case it's empty
         // and we've grabbed the most recent workspace
-        let (workspace_id, workspace_location, bounds, display, fullscreen, docks): (
+        let (workspace_id, workspace_location, bounds, display, fullscreen, centered_layout, docks): (
             WorkspaceId,
             WorkspaceLocation,
             Option<SerializedWindowsBounds>,
             Option<Uuid>,
             Option<bool>,
+            Option<bool>,
             DockStructure,
         ) = self
             .select_row_bound(sql! {
@@ -318,6 +324,7 @@ impl WorkspaceDb {
                     window_height,
                     display,
                     fullscreen,
+                    centered_layout,
                     left_dock_visible,
                     left_dock_active_panel,
                     left_dock_zoom,
@@ -344,6 +351,7 @@ impl WorkspaceDb {
                 .log_err()?,
             bounds: bounds.map(|bounds| bounds.0),
             fullscreen: fullscreen.unwrap_or(false),
+            centered_layout: centered_layout.unwrap_or(false),
             display,
             docks,
         })
@@ -678,6 +686,14 @@ impl WorkspaceDb {
             WHERE workspace_id = ?1
         }
     }
+
+    query! {
+        pub(crate) async fn set_centered_layout(workspace_id: WorkspaceId, centered_layout: bool) -> Result<()> {
+            UPDATE workspaces
+            SET centered_layout = ?2
+            WHERE workspace_id = ?1
+        }
+    }
 }
 
 #[cfg(test)]
@@ -764,6 +780,7 @@ mod tests {
             display: Default::default(),
             docks: Default::default(),
             fullscreen: false,
+            centered_layout: false,
         };
 
         let workspace_2 = SerializedWorkspace {
@@ -774,6 +791,7 @@ mod tests {
             display: Default::default(),
             docks: Default::default(),
             fullscreen: false,
+            centered_layout: false,
         };
 
         db.save_workspace(workspace_1.clone()).await;
@@ -873,6 +891,7 @@ mod tests {
             display: Default::default(),
             docks: Default::default(),
             fullscreen: false,
+            centered_layout: false,
         };
 
         db.save_workspace(workspace.clone()).await;
@@ -902,6 +921,7 @@ mod tests {
             display: Default::default(),
             docks: Default::default(),
             fullscreen: false,
+            centered_layout: false,
         };
 
         let mut workspace_2 = SerializedWorkspace {
@@ -912,6 +932,7 @@ mod tests {
             display: Default::default(),
             docks: Default::default(),
             fullscreen: false,
+            centered_layout: false,
         };
 
         db.save_workspace(workspace_1.clone()).await;
@@ -949,6 +970,7 @@ mod tests {
             display: Default::default(),
             docks: Default::default(),
             fullscreen: false,
+            centered_layout: false,
         };
 
         db.save_workspace(workspace_3.clone()).await;
@@ -983,6 +1005,7 @@ mod tests {
             display: Default::default(),
             docks: Default::default(),
             fullscreen: false,
+            centered_layout: false,
         }
     }
 

crates/workspace/src/persistence/model.rs 🔗

@@ -71,6 +71,7 @@ pub(crate) struct SerializedWorkspace {
     pub(crate) center_group: SerializedPaneGroup,
     pub(crate) bounds: Option<Bounds<DevicePixels>>,
     pub(crate) fullscreen: bool,
+    pub(crate) centered_layout: bool,
     pub(crate) display: Option<Uuid>,
     pub(crate) docks: DockStructure,
 }

crates/workspace/src/workspace.rs 🔗

@@ -26,7 +26,7 @@ use futures::{
     Future, FutureExt, StreamExt,
 };
 use gpui::{
-    actions, canvas, impl_actions, point, size, Action, AnyElement, AnyView, AnyWeakView,
+    actions, canvas, impl_actions, point, relative, size, Action, AnyElement, AnyView, AnyWeakView,
     AppContext, AsyncAppContext, AsyncWindowContext, Bounds, DevicePixels, DragMoveEvent,
     Entity as _, EntityId, EventEmitter, FocusHandle, FocusableView, Global, KeyContext, Keystroke,
     LayoutId, ManagedView, Model, ModelContext, PathPromptOptions, Point, PromptLevel, Render,
@@ -78,9 +78,9 @@ use theme::{ActiveTheme, SystemAppearance, ThemeSettings};
 pub use toolbar::{Toolbar, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView};
 pub use ui;
 use ui::{
-    div, Context as _, Div, Element, ElementContext, FluentBuilder as _, InteractiveElement as _,
-    IntoElement, Label, ParentElement as _, Pixels, SharedString, Styled as _, ViewContext,
-    VisualContext as _, WindowContext,
+    div, h_flex, Context as _, Div, Element, ElementContext, FluentBuilder,
+    InteractiveElement as _, IntoElement, Label, ParentElement as _, Pixels, SharedString,
+    Styled as _, ViewContext, VisualContext as _, WindowContext,
 };
 use util::ResultExt;
 use uuid::Uuid;
@@ -133,6 +133,7 @@ actions!(
         ToggleLeftDock,
         ToggleRightDock,
         ToggleBottomDock,
+        ToggleCenteredLayout,
         CloseAllDocks,
     ]
 );
@@ -581,6 +582,7 @@ pub struct Workspace {
     _schedule_serialize: Option<Task<()>>,
     pane_history_timestamp: Arc<AtomicUsize>,
     bounds: Bounds<Pixels>,
+    centered_layout: bool,
     bounds_save_task_queued: Option<Task<()>>,
 }
 
@@ -600,6 +602,9 @@ struct FollowerState {
 }
 
 impl Workspace {
+    const DEFAULT_PADDING: f32 = 0.2;
+    const MAX_PADDING: f32 = 0.4;
+
     pub fn new(
         workspace_id: WorkspaceId,
         project: Model<Project>,
@@ -867,6 +872,7 @@ impl Workspace {
             workspace_actions: Default::default(),
             // This data will be incorrect, but it will be overwritten by the time it needs to be used.
             bounds: Default::default(),
+            centered_layout: false,
             bounds_save_task_queued: None,
         }
     }
@@ -956,12 +962,19 @@ impl Workspace {
                 let mut options = cx.update(|cx| (app_state.build_window_options)(display, cx))?;
                 options.bounds = bounds;
                 options.fullscreen = fullscreen;
+                let centered_layout = serialized_workspace
+                    .as_ref()
+                    .map(|w| w.centered_layout)
+                    .unwrap_or(false);
                 cx.open_window(options, {
                     let app_state = app_state.clone();
                     let project_handle = project_handle.clone();
                     move |cx| {
                         cx.new_view(|cx| {
-                            Workspace::new(workspace_id, project_handle, app_state, cx)
+                            let mut workspace =
+                                Workspace::new(workspace_id, project_handle, app_state, cx);
+                            workspace.centered_layout = centered_layout;
+                            workspace
                         })
                     }
                 })?
@@ -3541,6 +3554,7 @@ impl Workspace {
                     display: Default::default(),
                     docks,
                     fullscreen: cx.is_fullscreen(),
+                    centered_layout: self.centered_layout,
                 };
                 return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace));
             }
@@ -3704,6 +3718,7 @@ impl Workspace {
                     workspace.reopen_closed_item(cx).detach();
                 }),
             )
+            .on_action(cx.listener(Workspace::toggle_centered_layout))
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -3772,6 +3787,21 @@ impl Workspace {
         self.modal_layer
             .update(cx, |modal_layer, cx| modal_layer.toggle_modal(cx, build))
     }
+
+    pub fn toggle_centered_layout(&mut self, _: &ToggleCenteredLayout, cx: &mut ViewContext<Self>) {
+        self.centered_layout = !self.centered_layout;
+        cx.background_executor()
+            .spawn(DB.set_centered_layout(self.database_id, self.centered_layout))
+            .detach_and_log_err(cx);
+        cx.notify();
+    }
+
+    fn adjust_padding(padding: Option<f32>) -> f32 {
+        padding
+            .unwrap_or(Self::DEFAULT_PADDING)
+            .min(Self::MAX_PADDING)
+            .max(0.0)
+    }
 }
 
 fn window_bounds_env_override() -> Option<Bounds<DevicePixels>> {
@@ -3916,7 +3946,27 @@ impl Render for Workspace {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let mut context = KeyContext::default();
         context.add("Workspace");
-
+        let centered_layout = self.centered_layout
+            && self.center.panes().len() == 1
+            && self.active_item(cx).is_some();
+        let render_padding = |size| {
+            (size > 0.0).then(|| {
+                div()
+                    .h_full()
+                    .w(relative(size))
+                    .bg(cx.theme().colors().editor_background)
+                    .border_color(cx.theme().colors().pane_group_border)
+            })
+        };
+        let paddings = if centered_layout {
+            let settings = WorkspaceSettings::get_global(cx).centered_layout;
+            (
+                render_padding(Self::adjust_padding(settings.left_padding)),
+                render_padding(Self::adjust_padding(settings.right_padding)),
+            )
+        } else {
+            (None, None)
+        };
         let (ui_font, ui_font_size) = {
             let theme_settings = ThemeSettings::get_global(cx);
             (
@@ -4009,15 +4059,25 @@ impl Render for Workspace {
                                     .flex_col()
                                     .flex_1()
                                     .overflow_hidden()
-                                    .child(self.center.render(
-                                        &self.project,
-                                        &self.follower_states,
-                                        self.active_call(),
-                                        &self.active_pane,
-                                        self.zoomed.as_ref(),
-                                        &self.app_state,
-                                        cx,
-                                    ))
+                                    .child(
+                                        h_flex()
+                                            .h_full()
+                                            .when_some(paddings.0, |this, p| {
+                                                this.child(p.border_r_1())
+                                            })
+                                            .child(self.center.render(
+                                                &self.project,
+                                                &self.follower_states,
+                                                self.active_call(),
+                                                &self.active_pane,
+                                                self.zoomed.as_ref(),
+                                                &self.app_state,
+                                                cx,
+                                            ))
+                                            .when_some(paddings.1, |this, p| {
+                                                this.child(p.border_l_1())
+                                            }),
+                                    )
                                     .children(
                                         self.zoomed_position
                                             .ne(&Some(DockPosition::Bottom))

crates/workspace/src/workspace_settings.rs 🔗

@@ -7,6 +7,7 @@ use settings::{Settings, SettingsSources};
 #[derive(Deserialize)]
 pub struct WorkspaceSettings {
     pub active_pane_magnification: f32,
+    pub centered_layout: CenteredLayoutSettings,
     pub confirm_quit: bool,
     pub show_call_status_icon: bool,
     pub autosave: AutosaveSetting,
@@ -31,6 +32,8 @@ pub struct WorkspaceSettingsContent {
     ///
     /// Default: `1.0`
     pub active_pane_magnification: Option<f32>,
+    // Centered layout related settings.
+    pub centered_layout: Option<CenteredLayoutSettings>,
     /// Whether or not to prompt the user to confirm before closing the application.
     ///
     /// Default: false
@@ -75,6 +78,21 @@ pub enum AutosaveSetting {
     OnWindowChange,
 }
 
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub struct CenteredLayoutSettings {
+    /// The relative width of the left padding of the central pane from the
+    /// workspace when the centered layout is used.
+    ///
+    /// Default: 0.2
+    pub left_padding: Option<f32>,
+    // The relative width of the right padding of the central pane from the
+    // workspace when the centered layout is used.
+    ///
+    /// Default: 0.2
+    pub right_padding: Option<f32>,
+}
+
 impl Settings for WorkspaceSettings {
     const KEY: Option<&'static str> = None;
 

docs/src/configuring_zed.md 🔗

@@ -142,6 +142,24 @@ For example, to disable ligatures for a given font you can add the following to
 
 `boolean` values
 
+## Centered Layout
+
+- Description: Configuration for the centered layout mode.
+- Setting: `centered_layout`
+- Default:
+
+```json
+"centered_layout": {
+  "left_padding": 0.2,
+  "right_padding": 0.2,
+}
+```
+
+**Options**
+
+The `left_padding` and `right_padding` options define the relative width of the
+left and right padding of the central pane from the workspace when the centered layout mode is activated. Valid values range is from `0` to `0.45`.
+
 ## Copilot
 
 - Description: Copilot-specific settings.