cursor_position.rs

  1use editor::{Editor, EditorSettings, MultiBufferSnapshot};
  2use gpui::{App, Entity, FocusHandle, Focusable, Subscription, Task, WeakEntity};
  3use schemars::JsonSchema;
  4use serde::{Deserialize, Serialize};
  5use settings::{Settings, SettingsKey, SettingsSources, SettingsUi};
  6use std::{fmt::Write, num::NonZeroU32, time::Duration};
  7use text::{Point, Selection};
  8use ui::{
  9    Button, ButtonCommon, Clickable, Context, FluentBuilder, IntoElement, LabelSize, ParentElement,
 10    Render, Tooltip, Window, div,
 11};
 12use util::paths::FILE_ROW_COLUMN_DELIMITER;
 13use workspace::{StatusItemView, Workspace, item::ItemHandle};
 14
 15#[derive(Copy, Clone, Debug, Default, PartialOrd, PartialEq)]
 16pub(crate) struct SelectionStats {
 17    pub lines: usize,
 18    pub characters: usize,
 19    pub selections: usize,
 20}
 21
 22pub struct CursorPosition {
 23    position: Option<UserCaretPosition>,
 24    selected_count: SelectionStats,
 25    context: Option<FocusHandle>,
 26    workspace: WeakEntity<Workspace>,
 27    update_position: Task<()>,
 28    _observe_active_editor: Option<Subscription>,
 29}
 30
 31/// A position in the editor, where user's caret is located at.
 32/// Lines are never zero as there is always at least one line in the editor.
 33/// Characters may start with zero as the caret may be at the beginning of a line, but all editors start counting characters from 1,
 34/// where "1" will mean "before the first character".
 35#[derive(Copy, Clone, Debug, PartialEq, Eq)]
 36pub struct UserCaretPosition {
 37    pub line: NonZeroU32,
 38    pub character: NonZeroU32,
 39}
 40
 41impl UserCaretPosition {
 42    pub(crate) fn at_selection_end(
 43        selection: &Selection<Point>,
 44        snapshot: &MultiBufferSnapshot,
 45    ) -> Self {
 46        let selection_end = selection.head();
 47        let (line, character) = if let Some((buffer_snapshot, point, _)) =
 48            snapshot.point_to_buffer_point(selection_end)
 49        {
 50            let line_start = Point::new(point.row, 0);
 51
 52            let chars_to_last_position = buffer_snapshot
 53                .text_summary_for_range::<text::TextSummary, _>(line_start..point)
 54                .chars as u32;
 55            (line_start.row, chars_to_last_position)
 56        } else {
 57            let line_start = Point::new(selection_end.row, 0);
 58
 59            let chars_to_last_position = snapshot
 60                .text_summary_for_range::<text::TextSummary, _>(line_start..selection_end)
 61                .chars as u32;
 62            (selection_end.row, chars_to_last_position)
 63        };
 64
 65        Self {
 66            line: NonZeroU32::new(line + 1).expect("added 1"),
 67            character: NonZeroU32::new(character + 1).expect("added 1"),
 68        }
 69    }
 70}
 71
 72impl CursorPosition {
 73    pub fn new(workspace: &Workspace) -> Self {
 74        Self {
 75            position: None,
 76            context: None,
 77            selected_count: Default::default(),
 78            workspace: workspace.weak_handle(),
 79            update_position: Task::ready(()),
 80            _observe_active_editor: None,
 81        }
 82    }
 83
 84    fn update_position(
 85        &mut self,
 86        editor: Entity<Editor>,
 87        debounce: Option<Duration>,
 88        window: &mut Window,
 89        cx: &mut Context<Self>,
 90    ) {
 91        let editor = editor.downgrade();
 92        self.update_position = cx.spawn_in(window, async move |cursor_position, cx| {
 93            let is_singleton = editor
 94                .update(cx, |editor, cx| editor.buffer().read(cx).is_singleton())
 95                .ok()
 96                .unwrap_or(true);
 97
 98            if !is_singleton && let Some(debounce) = debounce {
 99                cx.background_executor().timer(debounce).await;
100            }
101
102            editor
103                .update(cx, |editor, cx| {
104                    cursor_position.update(cx, |cursor_position, cx| {
105                        cursor_position.selected_count = SelectionStats::default();
106                        cursor_position.selected_count.selections = editor.selections.count();
107                        match editor.mode() {
108                            editor::EditorMode::AutoHeight { .. }
109                            | editor::EditorMode::SingleLine
110                            | editor::EditorMode::Minimap { .. } => {
111                                cursor_position.position = None;
112                                cursor_position.context = None;
113                            }
114                            editor::EditorMode::Full { .. } => {
115                                let mut last_selection = None::<Selection<Point>>;
116                                let snapshot = editor.buffer().read(cx).snapshot(cx);
117                                if snapshot.excerpts().count() > 0 {
118                                    for selection in editor.selections.all_adjusted(cx) {
119                                        let selection_summary = snapshot
120                                            .text_summary_for_range::<text::TextSummary, _>(
121                                                selection.start..selection.end,
122                                            );
123                                        cursor_position.selected_count.characters +=
124                                            selection_summary.chars;
125                                        if selection.end != selection.start {
126                                            cursor_position.selected_count.lines +=
127                                                (selection.end.row - selection.start.row) as usize;
128                                            if selection.end.column != 0 {
129                                                cursor_position.selected_count.lines += 1;
130                                            }
131                                        }
132                                        if last_selection.as_ref().is_none_or(|last_selection| {
133                                            selection.id > last_selection.id
134                                        }) {
135                                            last_selection = Some(selection);
136                                        }
137                                    }
138                                }
139                                cursor_position.position = last_selection
140                                    .map(|s| UserCaretPosition::at_selection_end(&s, &snapshot));
141                                cursor_position.context = Some(editor.focus_handle(cx));
142                            }
143                        }
144
145                        cx.notify();
146                    })
147                })
148                .ok()
149                .transpose()
150                .ok()
151                .flatten();
152        });
153    }
154
155    fn write_position(&self, text: &mut String, cx: &App) {
156        if self.selected_count
157            <= (SelectionStats {
158                selections: 1,
159                ..Default::default()
160            })
161        {
162            // Do not write out anything if we have just one empty selection.
163            return;
164        }
165        let SelectionStats {
166            lines,
167            characters,
168            selections,
169        } = self.selected_count;
170        let format = LineIndicatorFormat::get(None, cx);
171        let is_short_format = format == &LineIndicatorFormat::Short;
172        let lines = (lines > 1).then_some((lines, "line"));
173        let selections = (selections > 1).then_some((selections, "selection"));
174        let characters = (characters > 0).then_some((characters, "character"));
175        if (None, None, None) == (characters, selections, lines) {
176            // Nothing to display.
177            return;
178        }
179        write!(text, " (").unwrap();
180        let mut wrote_once = false;
181        for (count, name) in [selections, lines, characters].into_iter().flatten() {
182            if wrote_once {
183                write!(text, ", ").unwrap();
184            }
185            let name = if is_short_format { &name[..1] } else { name };
186            let plural_suffix = if count > 1 && !is_short_format {
187                "s"
188            } else {
189                ""
190            };
191            write!(text, "{count} {name}{plural_suffix}").unwrap();
192            wrote_once = true;
193        }
194        text.push(')');
195    }
196
197    #[cfg(test)]
198    pub(crate) fn selection_stats(&self) -> &SelectionStats {
199        &self.selected_count
200    }
201
202    #[cfg(test)]
203    pub(crate) fn position(&self) -> Option<UserCaretPosition> {
204        self.position
205    }
206}
207
208impl Render for CursorPosition {
209    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
210        if !EditorSettings::get_global(cx)
211            .status_bar
212            .cursor_position_button
213        {
214            return div();
215        }
216
217        div().when_some(self.position, |el, position| {
218            let mut text = format!(
219                "{}{FILE_ROW_COLUMN_DELIMITER}{}",
220                position.line, position.character,
221            );
222            self.write_position(&mut text, cx);
223
224            let context = self.context.clone();
225
226            el.child(
227                Button::new("go-to-line-column", text)
228                    .label_size(LabelSize::Small)
229                    .on_click(cx.listener(|this, _, window, cx| {
230                        if let Some(workspace) = this.workspace.upgrade() {
231                            workspace.update(cx, |workspace, cx| {
232                                if let Some(editor) = workspace
233                                    .active_item(cx)
234                                    .and_then(|item| item.act_as::<Editor>(cx))
235                                    && let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx)
236                                {
237                                    workspace.toggle_modal(window, cx, |window, cx| {
238                                        crate::GoToLine::new(editor, buffer, window, cx)
239                                    })
240                                }
241                            });
242                        }
243                    }))
244                    .tooltip(move |window, cx| match context.as_ref() {
245                        Some(context) => Tooltip::for_action_in(
246                            "Go to Line/Column",
247                            &editor::actions::ToggleGoToLine,
248                            context,
249                            window,
250                            cx,
251                        ),
252                        None => Tooltip::for_action(
253                            "Go to Line/Column",
254                            &editor::actions::ToggleGoToLine,
255                            window,
256                            cx,
257                        ),
258                    }),
259            )
260        })
261    }
262}
263
264const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
265
266impl StatusItemView for CursorPosition {
267    fn set_active_pane_item(
268        &mut self,
269        active_pane_item: Option<&dyn ItemHandle>,
270        window: &mut Window,
271        cx: &mut Context<Self>,
272    ) {
273        if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(cx)) {
274            self._observe_active_editor =
275                Some(
276                    cx.observe_in(&editor, window, |cursor_position, editor, window, cx| {
277                        Self::update_position(
278                            cursor_position,
279                            editor,
280                            Some(UPDATE_DEBOUNCE),
281                            window,
282                            cx,
283                        )
284                    }),
285                );
286            self.update_position(editor, None, window, cx);
287        } else {
288            self.position = None;
289            self._observe_active_editor = None;
290        }
291
292        cx.notify();
293    }
294}
295
296#[derive(Clone, Copy, Default, PartialEq, Debug, JsonSchema, Deserialize, Serialize)]
297#[serde(rename_all = "snake_case")]
298pub(crate) enum LineIndicatorFormat {
299    Short,
300    #[default]
301    Long,
302}
303
304#[derive(
305    Clone, Copy, Default, Debug, JsonSchema, Deserialize, Serialize, SettingsUi, SettingsKey,
306)]
307#[settings_key(None)]
308pub(crate) struct LineIndicatorFormatContent {
309    line_indicator_format: Option<LineIndicatorFormat>,
310}
311
312impl Settings for LineIndicatorFormat {
313    type FileContent = LineIndicatorFormatContent;
314
315    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
316        let format = [
317            sources.release_channel,
318            sources.profile,
319            sources.user,
320            sources.operating_system,
321            Some(sources.default),
322        ]
323        .into_iter()
324        .flatten()
325        .filter_map(|val| val.line_indicator_format)
326        .next()
327        .ok_or_else(Self::missing_default)?;
328
329        Ok(format)
330    }
331
332    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
333}