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