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