Introduce a status bar and add the cursor position to it

Antonio Scandurra , Nathan Sobo , and Max Brunsfeld created

Co-Authored-By: Nathan Sobo <nathan@zed.dev>
Co-Authored-By: Max Brunsfeld <max@zed.dev>

Change summary

crates/editor/src/lib.rs            |  15 +++
crates/gpui/src/app.rs              |   6 +
crates/theme/src/lib.rs             |   9 ++
crates/workspace/src/items.rs       |  84 ++++++++++++++++++++
crates/workspace/src/lib.rs         |  26 +++++
crates/workspace/src/status_bar.rs  | 127 +++++++++++++++++++++++++++++++
crates/zed/assets/themes/_base.toml |   8 +
7 files changed, 269 insertions(+), 6 deletions(-)

Detailed changes

crates/editor/src/lib.rs 🔗

@@ -2219,6 +2219,21 @@ impl Editor {
             .map(|(set_id, _)| *set_id)
     }
 
+    pub fn last_selection(&self, cx: &AppContext) -> Selection<Point> {
+        if let Some(pending_selection) = self.pending_selection.as_ref() {
+            pending_selection.clone()
+        } else {
+            let buffer = self.buffer.read(cx);
+            let last_selection = buffer
+                .selection_set(self.selection_set_id)
+                .unwrap()
+                .point_selections(buffer)
+                .max_by_key(|s| s.id)
+                .unwrap();
+            last_selection
+        }
+    }
+
     pub fn selections_in_range<'a>(
         &'a self,
         set_id: SelectionSetId,

crates/gpui/src/app.rs 🔗

@@ -2456,6 +2456,12 @@ impl<V: View> UpdateModel for RenderContext<'_, V> {
     }
 }
 
+impl<V: View> ReadView for RenderContext<'_, V> {
+    fn read_view<T: View>(&self, handle: &ViewHandle<T>) -> &T {
+        self.app.read_view(handle)
+    }
+}
+
 impl<M> AsRef<AppContext> for ViewContext<'_, M> {
     fn as_ref(&self) -> &AppContext {
         &self.app.cx

crates/theme/src/lib.rs 🔗

@@ -35,6 +35,7 @@ pub struct Workspace {
     pub pane_divider: Border,
     pub left_sidebar: Sidebar,
     pub right_sidebar: Sidebar,
+    pub status_bar: StatusBar,
 }
 
 #[derive(Clone, Deserialize, Default)]
@@ -88,6 +89,14 @@ pub struct SidebarItem {
     pub height: f32,
 }
 
+#[derive(Deserialize, Default)]
+pub struct StatusBar {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub height: f32,
+    pub cursor_position: TextStyle,
+}
+
 #[derive(Deserialize, Default)]
 pub struct ChatPanel {
     #[serde(flatten)]

crates/workspace/src/items.rs 🔗

@@ -1,11 +1,16 @@
 use super::{Item, ItemView};
-use crate::Settings;
+use crate::{status_bar::StatusItemView, Settings};
 use anyhow::Result;
+use buffer::{Point, ToOffset};
 use editor::{Editor, EditorSettings, Event};
-use gpui::{fonts::TextStyle, AppContext, ModelHandle, Task, ViewContext};
+use gpui::{
+    elements::*, fonts::TextStyle, AppContext, Entity, ModelHandle, RenderContext, Subscription,
+    Task, View, ViewContext, ViewHandle,
+};
 use language::{Buffer, File as _};
 use postage::watch;
 use project::{ProjectPath, Worktree};
+use std::fmt::Write;
 use std::path::Path;
 
 impl Item for Buffer {
@@ -156,3 +161,78 @@ impl ItemView for Editor {
         self.buffer().read(cx).has_conflict()
     }
 }
+
+pub struct CursorPosition {
+    position: Option<Point>,
+    selected_count: usize,
+    settings: watch::Receiver<Settings>,
+    _observe_active_editor: Option<Subscription>,
+}
+
+impl CursorPosition {
+    pub fn new(settings: watch::Receiver<Settings>) -> Self {
+        Self {
+            position: None,
+            selected_count: 0,
+            settings,
+            _observe_active_editor: None,
+        }
+    }
+
+    fn update_position(&mut self, editor: ViewHandle<Editor>, cx: &mut ViewContext<Self>) {
+        let editor = editor.read(cx);
+
+        let last_selection = editor.last_selection(cx);
+        self.position = Some(last_selection.head());
+        if last_selection.is_empty() {
+            self.selected_count = 0;
+        } else {
+            let buffer = editor.buffer().read(cx);
+            let start = last_selection.start.to_offset(buffer);
+            let end = last_selection.end.to_offset(buffer);
+            self.selected_count = end - start;
+        }
+        cx.notify();
+    }
+}
+
+impl Entity for CursorPosition {
+    type Event = ();
+}
+
+impl View for CursorPosition {
+    fn ui_name() -> &'static str {
+        "CursorPosition"
+    }
+
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        if let Some(position) = self.position {
+            let theme = &self.settings.borrow().theme.workspace.status_bar;
+            let mut text = format!("{},{}", position.row + 1, position.column + 1);
+            if self.selected_count > 0 {
+                write!(text, " ({} selected)", self.selected_count).unwrap();
+            }
+            Label::new(text, theme.cursor_position.clone()).boxed()
+        } else {
+            Empty::new().boxed()
+        }
+    }
+}
+
+impl StatusItemView for CursorPosition {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn crate::ItemViewHandle>,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let Some(editor) = active_pane_item.and_then(|item| item.to_any().downcast::<Editor>()) {
+            self._observe_active_editor = Some(cx.observe(&editor, Self::update_position));
+            self.update_position(editor, cx);
+        } else {
+            self.position = None;
+            self._observe_active_editor = None;
+        }
+
+        cx.notify();
+    }
+}

crates/workspace/src/lib.rs 🔗

@@ -3,15 +3,16 @@ pub mod pane;
 pub mod pane_group;
 pub mod settings;
 pub mod sidebar;
+mod status_bar;
 
 use anyhow::Result;
-use language::{Buffer, LanguageRegistry};
 use client::{Authenticate, ChannelList, Client, UserStore};
 use gpui::{
     action, elements::*, json::to_string_pretty, keymap::Binding, platform::CursorStyle,
     AnyViewHandle, AppContext, ClipboardItem, Entity, ModelHandle, MutableAppContext, PromptLevel,
     RenderContext, Task, View, ViewContext, ViewHandle, WeakModelHandle,
 };
+use language::{Buffer, LanguageRegistry};
 use log::error;
 pub use pane::*;
 pub use pane_group::*;
@@ -26,6 +27,8 @@ use std::{
     sync::Arc,
 };
 
+use crate::status_bar::StatusBar;
+
 action!(OpenNew, WorkspaceParams);
 action!(Save);
 action!(DebugElements);
@@ -311,6 +314,7 @@ pub struct Workspace {
     right_sidebar: Sidebar,
     panes: Vec<ViewHandle<Pane>>,
     active_pane: ViewHandle<Pane>,
+    status_bar: ViewHandle<StatusBar>,
     project: ModelHandle<Project>,
     items: Vec<Box<dyn WeakItemHandle>>,
     loading_items: HashMap<
@@ -345,6 +349,13 @@ impl Workspace {
         .detach();
         cx.focus(&pane);
 
+        let cursor_position = cx.add_view(|_| items::CursorPosition::new(params.settings.clone()));
+        let status_bar = cx.add_view(|cx| {
+            let mut status_bar = StatusBar::new(&pane, params.settings.clone(), cx);
+            status_bar.add_right_item(cursor_position, cx);
+            status_bar
+        });
+
         let mut current_user = params.user_store.read(cx).watch_current_user().clone();
         let mut connection_status = params.client.status().clone();
         let _observe_current_user = cx.spawn_weak(|this, mut cx| async move {
@@ -367,6 +378,7 @@ impl Workspace {
             center: PaneGroup::new(pane.id()),
             panes: vec![pane.clone()],
             active_pane: pane.clone(),
+            status_bar,
             settings: params.settings.clone(),
             client: params.client.clone(),
             user_store: params.user_store.clone(),
@@ -824,6 +836,9 @@ impl Workspace {
 
     fn activate_pane(&mut self, pane: ViewHandle<Pane>, cx: &mut ViewContext<Self>) {
         self.active_pane = pane;
+        self.status_bar.update(cx, |status_bar, cx| {
+            status_bar.set_active_pane(&self.active_pane, cx);
+        });
         cx.focus(&self.active_pane);
         cx.notify();
     }
@@ -1017,7 +1032,14 @@ impl View for Workspace {
                                     content.add_child(Flexible::new(0.8, element).boxed());
                                 }
                                 content.add_child(
-                                    Expanded::new(1.0, self.center.render(&settings.theme)).boxed(),
+                                    Flex::column()
+                                        .with_child(
+                                            Expanded::new(1.0, self.center.render(&settings.theme))
+                                                .boxed(),
+                                        )
+                                        .with_child(ChildView::new(self.status_bar.id()).boxed())
+                                        .expanded(1.)
+                                        .boxed(),
                                 );
                                 if let Some(element) =
                                     self.right_sidebar.render_active_item(&settings, cx)

crates/workspace/src/status_bar.rs 🔗

@@ -0,0 +1,127 @@
+use crate::{ItemViewHandle, Pane, Settings};
+use gpui::{
+    elements::*, ElementBox, Entity, MutableAppContext, RenderContext, Subscription, View,
+    ViewContext, ViewHandle,
+};
+use postage::watch;
+
+pub trait StatusItemView: View {
+    fn set_active_pane_item(
+        &mut self,
+        active_pane_item: Option<&dyn crate::ItemViewHandle>,
+        cx: &mut ViewContext<Self>,
+    );
+}
+
+trait StatusItemViewHandle {
+    fn id(&self) -> usize;
+    fn set_active_pane_item(
+        &self,
+        active_pane_item: Option<&dyn ItemViewHandle>,
+        cx: &mut MutableAppContext,
+    );
+}
+
+pub struct StatusBar {
+    left_items: Vec<Box<dyn StatusItemViewHandle>>,
+    right_items: Vec<Box<dyn StatusItemViewHandle>>,
+    active_pane: ViewHandle<Pane>,
+    _observe_active_pane: Subscription,
+    settings: watch::Receiver<Settings>,
+}
+
+impl Entity for StatusBar {
+    type Event = ();
+}
+
+impl View for StatusBar {
+    fn ui_name() -> &'static str {
+        "StatusBar"
+    }
+
+    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
+        let theme = &self.settings.borrow().theme.workspace.status_bar;
+        Flex::row()
+            .with_children(
+                self.left_items
+                    .iter()
+                    .map(|i| ChildView::new(i.id()).aligned().boxed()),
+            )
+            .with_child(Empty::new().expanded(1.).boxed())
+            .with_children(
+                self.right_items
+                    .iter()
+                    .map(|i| ChildView::new(i.id()).aligned().boxed()),
+            )
+            .contained()
+            .with_style(theme.container)
+            .constrained()
+            .with_height(theme.height)
+            .boxed()
+    }
+}
+
+impl StatusBar {
+    pub fn new(
+        active_pane: &ViewHandle<Pane>,
+        settings: watch::Receiver<Settings>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        let mut this = Self {
+            left_items: Default::default(),
+            right_items: Default::default(),
+            active_pane: active_pane.clone(),
+            _observe_active_pane: cx
+                .observe(active_pane, |this, _, cx| this.update_active_pane_item(cx)),
+            settings,
+        };
+        this.update_active_pane_item(cx);
+        this
+    }
+
+    pub fn add_left_item<T>(&mut self, item: ViewHandle<T>, cx: &mut ViewContext<Self>)
+    where
+        T: 'static + StatusItemView,
+    {
+        self.left_items.push(Box::new(item));
+        cx.notify();
+    }
+
+    pub fn add_right_item<T>(&mut self, item: ViewHandle<T>, cx: &mut ViewContext<Self>)
+    where
+        T: 'static + StatusItemView,
+    {
+        self.right_items.push(Box::new(item));
+        cx.notify();
+    }
+
+    pub fn set_active_pane(&mut self, active_pane: &ViewHandle<Pane>, cx: &mut ViewContext<Self>) {
+        self.active_pane = active_pane.clone();
+        self._observe_active_pane =
+            cx.observe(active_pane, |this, _, cx| this.update_active_pane_item(cx));
+        self.update_active_pane_item(cx);
+    }
+
+    fn update_active_pane_item(&mut self, cx: &mut ViewContext<Self>) {
+        let active_pane_item = self.active_pane.read(cx).active_item();
+        for item in self.left_items.iter().chain(&self.right_items) {
+            item.set_active_pane_item(active_pane_item.as_deref(), cx);
+        }
+    }
+}
+
+impl<T: StatusItemView> StatusItemViewHandle for ViewHandle<T> {
+    fn id(&self) -> usize {
+        self.id()
+    }
+
+    fn set_active_pane_item(
+        &self,
+        active_pane_item: Option<&dyn ItemViewHandle>,
+        cx: &mut MutableAppContext,
+    ) {
+        self.update(cx, |this, cx| {
+            this.set_active_pane_item(active_pane_item, cx)
+        });
+    }
+}

crates/zed/assets/themes/_base.toml 🔗

@@ -60,6 +60,11 @@ border = { width = 1, color = "$border.0", right = true }
 extends = "$workspace.sidebar"
 border = { width = 1, color = "$border.0", left = true }
 
+[workspace.status_bar]
+padding = { left = 6, right = 6 }
+height = 24
+cursor_position = "$text.2"
+
 [panel]
 padding = { top = 12, left = 12, bottom = 12, right = 12 }
 
@@ -164,7 +169,7 @@ corner_radius = 6
 
 [project_panel]
 extends = "$panel"
-padding.top = 6    # ($workspace.tab.height - $project_panel.entry.height) / 2
+padding.top = 6 # ($workspace.tab.height - $project_panel.entry.height) / 2
 
 [project_panel.entry]
 text = "$text.1"
@@ -226,7 +231,6 @@ line_number = "$text.2.color"
 line_number_active = "$text.0.color"
 selection = "$selection.host"
 guest_selections = "$selection.guests"
-
 error_underline = "$status.bad"
 warning_underline = "$status.warn"
 info_underline = "$status.info"