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, SettingsSources};
  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 {
 99                if let Some(debounce) = debounce {
100                    cx.background_executor().timer(debounce).await;
101                }
102            }
103
104            editor
105                .update(cx, |editor, cx| {
106                    cursor_position.update(cx, |cursor_position, cx| {
107                        cursor_position.selected_count = SelectionStats::default();
108                        cursor_position.selected_count.selections = editor.selections.count();
109                        match editor.mode() {
110                            editor::EditorMode::AutoHeight { .. }
111                            | editor::EditorMode::SingleLine { .. }
112                            | editor::EditorMode::Minimap { .. } => {
113                                cursor_position.position = None;
114                                cursor_position.context = None;
115                            }
116                            editor::EditorMode::Full { .. } => {
117                                let mut last_selection = None::<Selection<Point>>;
118                                let snapshot = editor.buffer().read(cx).snapshot(cx);
119                                if snapshot.excerpts().count() > 0 {
120                                    for selection in editor.selections.all_adjusted(cx) {
121                                        let selection_summary = snapshot
122                                            .text_summary_for_range::<text::TextSummary, _>(
123                                                selection.start..selection.end,
124                                            );
125                                        cursor_position.selected_count.characters +=
126                                            selection_summary.chars;
127                                        if selection.end != selection.start {
128                                            cursor_position.selected_count.lines +=
129                                                (selection.end.row - selection.start.row) as usize;
130                                            if selection.end.column != 0 {
131                                                cursor_position.selected_count.lines += 1;
132                                            }
133                                        }
134                                        if last_selection.as_ref().map_or(true, |last_selection| {
135                                            selection.id > last_selection.id
136                                        }) {
137                                            last_selection = Some(selection);
138                                        }
139                                    }
140                                }
141                                cursor_position.position = last_selection
142                                    .map(|s| UserCaretPosition::at_selection_end(&s, &snapshot));
143                                cursor_position.context = Some(editor.focus_handle(cx));
144                            }
145                        }
146
147                        cx.notify();
148                    })
149                })
150                .ok()
151                .transpose()
152                .ok()
153                .flatten();
154        });
155    }
156
157    fn write_position(&self, text: &mut String, cx: &App) {
158        if self.selected_count
159            <= (SelectionStats {
160                selections: 1,
161                ..Default::default()
162            })
163        {
164            // Do not write out anything if we have just one empty selection.
165            return;
166        }
167        let SelectionStats {
168            lines,
169            characters,
170            selections,
171        } = self.selected_count;
172        let format = LineIndicatorFormat::get(None, cx);
173        let is_short_format = format == &LineIndicatorFormat::Short;
174        let lines = (lines > 1).then_some((lines, "line"));
175        let selections = (selections > 1).then_some((selections, "selection"));
176        let characters = (characters > 0).then_some((characters, "character"));
177        if (None, None, None) == (characters, selections, lines) {
178            // Nothing to display.
179            return;
180        }
181        write!(text, " (").unwrap();
182        let mut wrote_once = false;
183        for (count, name) in [selections, lines, characters].into_iter().flatten() {
184            if wrote_once {
185                write!(text, ", ").unwrap();
186            }
187            let name = if is_short_format { &name[..1] } else { name };
188            let plural_suffix = if count > 1 && !is_short_format {
189                "s"
190            } else {
191                ""
192            };
193            write!(text, "{count} {name}{plural_suffix}").unwrap();
194            wrote_once = true;
195        }
196        text.push(')');
197    }
198
199    #[cfg(test)]
200    pub(crate) fn selection_stats(&self) -> &SelectionStats {
201        &self.selected_count
202    }
203
204    #[cfg(test)]
205    pub(crate) fn position(&self) -> Option<UserCaretPosition> {
206        self.position
207    }
208}
209
210impl Render for CursorPosition {
211    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
212        if !EditorSettings::get_global(cx)
213            .status_bar
214            .cursor_position_button
215        {
216            return div();
217        }
218
219        div().when_some(self.position, |el, position| {
220            let mut text = format!(
221                "{}{FILE_ROW_COLUMN_DELIMITER}{}",
222                position.line, position.character,
223            );
224            self.write_position(&mut text, cx);
225
226            let context = self.context.clone();
227
228            el.child(
229                Button::new("go-to-line-column", text)
230                    .label_size(LabelSize::Small)
231                    .on_click(cx.listener(|this, _, window, cx| {
232                        if let Some(workspace) = this.workspace.upgrade() {
233                            workspace.update(cx, |workspace, cx| {
234                                if let Some(editor) = workspace
235                                    .active_item(cx)
236                                    .and_then(|item| item.act_as::<Editor>(cx))
237                                {
238                                    if let Some((_, buffer, _)) = editor.read(cx).active_excerpt(cx)
239                                    {
240                                        workspace.toggle_modal(window, cx, |window, cx| {
241                                            crate::GoToLine::new(editor, buffer, window, cx)
242                                        })
243                                    }
244                                }
245                            });
246                        }
247                    }))
248                    .tooltip(move |window, cx| match context.as_ref() {
249                        Some(context) => Tooltip::for_action_in(
250                            "Go to Line/Column",
251                            &editor::actions::ToggleGoToLine,
252                            context,
253                            window,
254                            cx,
255                        ),
256                        None => Tooltip::for_action(
257                            "Go to Line/Column",
258                            &editor::actions::ToggleGoToLine,
259                            window,
260                            cx,
261                        ),
262                    }),
263            )
264        })
265    }
266}
267
268const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50);
269
270impl StatusItemView for CursorPosition {
271    fn set_active_pane_item(
272        &mut self,
273        active_pane_item: Option<&dyn ItemHandle>,
274        window: &mut Window,
275        cx: &mut Context<Self>,
276    ) {
277        if let Some(editor) = active_pane_item.and_then(|item| item.act_as::<Editor>(cx)) {
278            self._observe_active_editor =
279                Some(
280                    cx.observe_in(&editor, window, |cursor_position, editor, window, cx| {
281                        Self::update_position(
282                            cursor_position,
283                            editor,
284                            Some(UPDATE_DEBOUNCE),
285                            window,
286                            cx,
287                        )
288                    }),
289                );
290            self.update_position(editor, None, window, cx);
291        } else {
292            self.position = None;
293            self._observe_active_editor = None;
294        }
295
296        cx.notify();
297    }
298}
299
300#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize)]
301#[serde(rename_all = "snake_case")]
302pub(crate) enum LineIndicatorFormat {
303    Short,
304    #[default]
305    Long,
306}
307
308#[derive(Clone, Copy, Default, JsonSchema, Deserialize, Serialize)]
309#[serde(transparent)]
310pub(crate) struct LineIndicatorFormatContent(LineIndicatorFormat);
311
312impl Settings for LineIndicatorFormat {
313    const KEY: Option<&'static str> = Some("line_indicator_format");
314
315    type FileContent = Option<LineIndicatorFormatContent>;
316
317    fn load(sources: SettingsSources<Self::FileContent>, _: &mut App) -> anyhow::Result<Self> {
318        let format = [
319            sources.release_channel,
320            sources.operating_system,
321            sources.user,
322        ]
323        .into_iter()
324        .find_map(|value| value.copied().flatten())
325        .unwrap_or(sources.default.ok_or_else(Self::missing_default)?);
326
327        Ok(format.0)
328    }
329
330    fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
331}