outline.rs

  1use editor::{
  2    display_map::ToDisplayPoint, Anchor, AnchorRangeExt, Autoscroll, Editor, EditorSettings,
  3    ToPoint,
  4};
  5use fuzzy::StringMatch;
  6use gpui::{
  7    action,
  8    elements::*,
  9    fonts::{self, HighlightStyle},
 10    geometry::vector::Vector2F,
 11    keymap::{
 12        self,
 13        menu::{SelectNext, SelectPrev},
 14        Binding,
 15    },
 16    AppContext, Axis, Entity, MutableAppContext, RenderContext, View, ViewContext, ViewHandle,
 17    WeakViewHandle,
 18};
 19use language::{Outline, Selection};
 20use ordered_float::OrderedFloat;
 21use postage::watch;
 22use std::{
 23    cmp::{self, Reverse},
 24    ops::Range,
 25    sync::Arc,
 26};
 27use workspace::{Settings, Workspace};
 28
 29action!(Toggle);
 30action!(Confirm);
 31
 32pub fn init(cx: &mut MutableAppContext) {
 33    cx.add_bindings([
 34        Binding::new("cmd-shift-O", Toggle, Some("Editor")),
 35        Binding::new("escape", Toggle, Some("OutlineView")),
 36        Binding::new("enter", Confirm, Some("OutlineView")),
 37    ]);
 38    cx.add_action(OutlineView::toggle);
 39    cx.add_action(OutlineView::confirm);
 40    cx.add_action(OutlineView::select_prev);
 41    cx.add_action(OutlineView::select_next);
 42}
 43
 44struct OutlineView {
 45    handle: WeakViewHandle<Self>,
 46    active_editor: ViewHandle<Editor>,
 47    outline: Outline<Anchor>,
 48    selected_match_index: usize,
 49    restore_state: Option<RestoreState>,
 50    symbol_selection_id: Option<usize>,
 51    matches: Vec<StringMatch>,
 52    query_editor: ViewHandle<Editor>,
 53    list_state: UniformListState,
 54    settings: watch::Receiver<Settings>,
 55}
 56
 57struct RestoreState {
 58    scroll_position: Vector2F,
 59    selections: Vec<Selection<usize>>,
 60}
 61
 62pub enum Event {
 63    Dismissed,
 64}
 65
 66impl Entity for OutlineView {
 67    type Event = Event;
 68
 69    fn release(&mut self, cx: &mut MutableAppContext) {
 70        self.restore_active_editor(cx);
 71    }
 72}
 73
 74impl View for OutlineView {
 75    fn ui_name() -> &'static str {
 76        "OutlineView"
 77    }
 78
 79    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
 80        let mut cx = Self::default_keymap_context();
 81        cx.set.insert("menu".into());
 82        cx
 83    }
 84
 85    fn render(&mut self, _: &mut RenderContext<Self>) -> ElementBox {
 86        let settings = self.settings.borrow();
 87
 88        Align::new(
 89            ConstrainedBox::new(
 90                Container::new(
 91                    Flex::new(Axis::Vertical)
 92                        .with_child(
 93                            Container::new(ChildView::new(self.query_editor.id()).boxed())
 94                                .with_style(settings.theme.selector.input_editor.container)
 95                                .boxed(),
 96                        )
 97                        .with_child(Flexible::new(1.0, false, self.render_matches()).boxed())
 98                        .boxed(),
 99                )
100                .with_style(settings.theme.selector.container)
101                .boxed(),
102            )
103            .with_max_width(800.0)
104            .with_max_height(1200.0)
105            .boxed(),
106        )
107        .top()
108        .named("outline view")
109    }
110
111    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
112        cx.focus(&self.query_editor);
113    }
114}
115
116impl OutlineView {
117    fn new(
118        outline: Outline<Anchor>,
119        editor: ViewHandle<Editor>,
120        settings: watch::Receiver<Settings>,
121        cx: &mut ViewContext<Self>,
122    ) -> Self {
123        let query_editor = cx.add_view(|cx| {
124            Editor::single_line(
125                {
126                    let settings = settings.clone();
127                    Arc::new(move |_| {
128                        let settings = settings.borrow();
129                        EditorSettings {
130                            style: settings.theme.selector.input_editor.as_editor(),
131                            tab_size: settings.tab_size,
132                            soft_wrap: editor::SoftWrap::None,
133                        }
134                    })
135                },
136                cx,
137            )
138        });
139        cx.subscribe(&query_editor, Self::on_query_editor_event)
140            .detach();
141
142        let restore_state = editor.update(cx, |editor, cx| {
143            Some(RestoreState {
144                scroll_position: editor.scroll_position(cx),
145                selections: editor.local_selections::<usize>(cx),
146            })
147        });
148
149        let mut this = Self {
150            handle: cx.weak_handle(),
151            active_editor: editor,
152            matches: Default::default(),
153            selected_match_index: 0,
154            restore_state,
155            symbol_selection_id: None,
156            outline,
157            query_editor,
158            list_state: Default::default(),
159            settings,
160        };
161        this.update_matches(cx);
162        this
163    }
164
165    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
166        let editor = workspace
167            .active_item(cx)
168            .unwrap()
169            .to_any()
170            .downcast::<Editor>()
171            .unwrap();
172        let settings = workspace.settings();
173        let buffer = editor
174            .read(cx)
175            .buffer()
176            .read(cx)
177            .read(cx)
178            .outline(Some(settings.borrow().theme.editor.syntax.as_ref()));
179        if let Some(outline) = buffer {
180            workspace.toggle_modal(cx, |cx, _| {
181                let view = cx.add_view(|cx| OutlineView::new(outline, editor, settings, cx));
182                cx.subscribe(&view, Self::on_event).detach();
183                view
184            })
185        }
186    }
187
188    fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext<Self>) {
189        if self.selected_match_index > 0 {
190            self.select(self.selected_match_index - 1, true, cx);
191        }
192    }
193
194    fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext<Self>) {
195        if self.selected_match_index + 1 < self.matches.len() {
196            self.select(self.selected_match_index + 1, true, cx);
197        }
198    }
199
200    fn select(&mut self, index: usize, navigate: bool, cx: &mut ViewContext<Self>) {
201        self.selected_match_index = index;
202        self.list_state.scroll_to(self.selected_match_index);
203        if navigate {
204            let selected_match = &self.matches[self.selected_match_index];
205            let outline_item = &self.outline.items[selected_match.candidate_id];
206            self.symbol_selection_id = self.active_editor.update(cx, |active_editor, cx| {
207                let snapshot = active_editor.snapshot(cx).display_snapshot;
208                let buffer_snapshot = &snapshot.buffer_snapshot;
209                let start = outline_item.range.start.to_point(&buffer_snapshot);
210                let end = outline_item.range.end.to_point(&buffer_snapshot);
211                let display_rows = start.to_display_point(&snapshot).row()
212                    ..end.to_display_point(&snapshot).row() + 1;
213                active_editor.select_ranges([start..start], Some(Autoscroll::Center), cx);
214                active_editor.set_highlighted_rows(Some(display_rows));
215                Some(active_editor.newest_selection::<usize>(&buffer_snapshot).id)
216            });
217            cx.notify();
218        }
219    }
220
221    fn confirm(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
222        self.restore_state.take();
223        cx.emit(Event::Dismissed);
224    }
225
226    fn restore_active_editor(&mut self, cx: &mut MutableAppContext) {
227        let symbol_selection_id = self.symbol_selection_id.take();
228        self.active_editor.update(cx, |editor, cx| {
229            editor.set_highlighted_rows(None);
230            if let Some((symbol_selection_id, restore_state)) =
231                symbol_selection_id.zip(self.restore_state.as_ref())
232            {
233                let newest_selection =
234                    editor.newest_selection::<usize>(&editor.buffer().read(cx).read(cx));
235                if symbol_selection_id == newest_selection.id {
236                    editor.set_scroll_position(restore_state.scroll_position, cx);
237                    editor.update_selections(restore_state.selections.clone(), None, cx);
238                }
239            }
240        })
241    }
242
243    fn on_event(
244        workspace: &mut Workspace,
245        _: ViewHandle<Self>,
246        event: &Event,
247        cx: &mut ViewContext<Workspace>,
248    ) {
249        match event {
250            Event::Dismissed => workspace.dismiss_modal(cx),
251        }
252    }
253
254    fn on_query_editor_event(
255        &mut self,
256        _: ViewHandle<Editor>,
257        event: &editor::Event,
258        cx: &mut ViewContext<Self>,
259    ) {
260        match event {
261            editor::Event::Edited => self.update_matches(cx),
262            _ => {}
263        }
264    }
265
266    fn update_matches(&mut self, cx: &mut ViewContext<Self>) {
267        let selected_index;
268        let navigate_to_selected_index;
269        let query = self.query_editor.update(cx, |buffer, cx| buffer.text(cx));
270        if query.is_empty() {
271            self.restore_active_editor(cx);
272            self.matches = self
273                .outline
274                .items
275                .iter()
276                .enumerate()
277                .map(|(index, _)| StringMatch {
278                    candidate_id: index,
279                    score: Default::default(),
280                    positions: Default::default(),
281                    string: Default::default(),
282                })
283                .collect();
284
285            let editor = self.active_editor.read(cx);
286            let buffer = editor.buffer().read(cx).read(cx);
287            let cursor_offset = editor.newest_selection::<usize>(&buffer).head();
288            selected_index = self
289                .outline
290                .items
291                .iter()
292                .enumerate()
293                .map(|(ix, item)| {
294                    let range = item.range.to_offset(&buffer);
295                    let distance_to_closest_endpoint = cmp::min(
296                        (range.start as isize - cursor_offset as isize).abs() as usize,
297                        (range.end as isize - cursor_offset as isize).abs() as usize,
298                    );
299                    let depth = if range.contains(&cursor_offset) {
300                        Some(item.depth)
301                    } else {
302                        None
303                    };
304                    (ix, depth, distance_to_closest_endpoint)
305                })
306                .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance)))
307                .unwrap()
308                .0;
309            navigate_to_selected_index = false;
310        } else {
311            self.matches = smol::block_on(self.outline.search(&query, cx.background().clone()));
312            selected_index = self
313                .matches
314                .iter()
315                .enumerate()
316                .max_by_key(|(_, m)| OrderedFloat(m.score))
317                .map(|(ix, _)| ix)
318                .unwrap_or(0);
319            navigate_to_selected_index = !self.matches.is_empty();
320        }
321        self.select(selected_index, navigate_to_selected_index, cx);
322    }
323
324    fn render_matches(&self) -> ElementBox {
325        if self.matches.is_empty() {
326            let settings = self.settings.borrow();
327            return Container::new(
328                Label::new(
329                    "No matches".into(),
330                    settings.theme.selector.empty.label.clone(),
331                )
332                .boxed(),
333            )
334            .with_style(settings.theme.selector.empty.container)
335            .named("empty matches");
336        }
337
338        let handle = self.handle.clone();
339        let list = UniformList::new(
340            self.list_state.clone(),
341            self.matches.len(),
342            move |mut range, items, cx| {
343                let cx = cx.as_ref();
344                let view = handle.upgrade(cx).unwrap();
345                let view = view.read(cx);
346                let start = range.start;
347                range.end = cmp::min(range.end, view.matches.len());
348                items.extend(
349                    view.matches[range]
350                        .iter()
351                        .enumerate()
352                        .map(move |(ix, m)| view.render_match(m, start + ix)),
353                );
354            },
355        );
356
357        Container::new(list.boxed())
358            .with_margin_top(6.0)
359            .named("matches")
360    }
361
362    fn render_match(&self, string_match: &StringMatch, index: usize) -> ElementBox {
363        let settings = self.settings.borrow();
364        let style = if index == self.selected_match_index {
365            &settings.theme.selector.active_item
366        } else {
367            &settings.theme.selector.item
368        };
369        let outline_item = &self.outline.items[string_match.candidate_id];
370
371        Text::new(outline_item.text.clone(), style.label.text.clone())
372            .with_soft_wrap(false)
373            .with_highlights(combine_syntax_and_fuzzy_match_highlights(
374                &outline_item.text,
375                style.label.text.clone().into(),
376                &outline_item.highlight_ranges,
377                &string_match.positions,
378            ))
379            .contained()
380            .with_padding_left(20. * outline_item.depth as f32)
381            .contained()
382            .with_style(style.container)
383            .boxed()
384    }
385}
386
387fn combine_syntax_and_fuzzy_match_highlights(
388    text: &str,
389    default_style: HighlightStyle,
390    syntax_ranges: &[(Range<usize>, HighlightStyle)],
391    match_indices: &[usize],
392) -> Vec<(Range<usize>, HighlightStyle)> {
393    let mut result = Vec::new();
394    let mut match_indices = match_indices.iter().copied().peekable();
395
396    for (range, mut syntax_highlight) in syntax_ranges
397        .iter()
398        .cloned()
399        .chain([(usize::MAX..0, Default::default())])
400    {
401        syntax_highlight.font_properties.weight(Default::default());
402
403        // Add highlights for any fuzzy match characters before the next
404        // syntax highlight range.
405        while let Some(&match_index) = match_indices.peek() {
406            if match_index >= range.start {
407                break;
408            }
409            match_indices.next();
410            let end_index = char_ix_after(match_index, text);
411            let mut match_style = default_style;
412            match_style.font_properties.weight(fonts::Weight::BOLD);
413            result.push((match_index..end_index, match_style));
414        }
415
416        if range.start == usize::MAX {
417            break;
418        }
419
420        // Add highlights for any fuzzy match characters within the
421        // syntax highlight range.
422        let mut offset = range.start;
423        while let Some(&match_index) = match_indices.peek() {
424            if match_index >= range.end {
425                break;
426            }
427
428            match_indices.next();
429            if match_index > offset {
430                result.push((offset..match_index, syntax_highlight));
431            }
432
433            let mut end_index = char_ix_after(match_index, text);
434            while let Some(&next_match_index) = match_indices.peek() {
435                if next_match_index == end_index && next_match_index < range.end {
436                    end_index = char_ix_after(next_match_index, text);
437                    match_indices.next();
438                } else {
439                    break;
440                }
441            }
442
443            let mut match_style = syntax_highlight;
444            match_style.font_properties.weight(fonts::Weight::BOLD);
445            result.push((match_index..end_index, match_style));
446            offset = end_index;
447        }
448
449        if offset < range.end {
450            result.push((offset..range.end, syntax_highlight));
451        }
452    }
453
454    result
455}
456
457fn char_ix_after(ix: usize, text: &str) -> usize {
458    ix + text[ix..].chars().next().unwrap().len_utf8()
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use gpui::{color::Color, fonts::HighlightStyle};
465
466    #[test]
467    fn test_combine_syntax_and_fuzzy_match_highlights() {
468        let string = "abcdefghijklmnop";
469        let default = HighlightStyle::default();
470        let syntax_ranges = [
471            (
472                0..3,
473                HighlightStyle {
474                    color: Color::red(),
475                    ..default
476                },
477            ),
478            (
479                4..8,
480                HighlightStyle {
481                    color: Color::green(),
482                    ..default
483                },
484            ),
485        ];
486        let match_indices = [4, 6, 7, 8];
487        assert_eq!(
488            combine_syntax_and_fuzzy_match_highlights(
489                &string,
490                default,
491                &syntax_ranges,
492                &match_indices,
493            ),
494            &[
495                (
496                    0..3,
497                    HighlightStyle {
498                        color: Color::red(),
499                        ..default
500                    },
501                ),
502                (
503                    4..5,
504                    HighlightStyle {
505                        color: Color::green(),
506                        font_properties: *fonts::Properties::default().weight(fonts::Weight::BOLD),
507                        ..default
508                    },
509                ),
510                (
511                    5..6,
512                    HighlightStyle {
513                        color: Color::green(),
514                        ..default
515                    },
516                ),
517                (
518                    6..8,
519                    HighlightStyle {
520                        color: Color::green(),
521                        font_properties: *fonts::Properties::default().weight(fonts::Weight::BOLD),
522                        ..default
523                    },
524                ),
525                (
526                    8..9,
527                    HighlightStyle {
528                        font_properties: *fonts::Properties::default().weight(fonts::Weight::BOLD),
529                        ..default
530                    },
531                ),
532            ]
533        );
534    }
535}