indent_guides.rs

  1#![allow(missing_docs)]
  2use std::{cmp::Ordering, ops::Range, rc::Rc};
  3
  4use gpui::{
  5    fill, point, size, AnyElement, AppContext, Bounds, Hsla, Point, UniformListDecoration, View,
  6};
  7use smallvec::SmallVec;
  8
  9use crate::prelude::*;
 10
 11/// Represents the colors used for different states of indent guides.
 12#[derive(Debug, Clone)]
 13pub struct IndentGuideColors {
 14    /// The color of the indent guide when it's neither active nor hovered.
 15    pub default: Hsla,
 16    /// The color of the indent guide when it's hovered.
 17    pub hover: Hsla,
 18    /// The color of the indent guide when it's active.
 19    pub active: Hsla,
 20}
 21
 22impl IndentGuideColors {
 23    /// Returns the indent guide colors that should be used for panels.
 24    pub fn panel(cx: &AppContext) -> Self {
 25        Self {
 26            default: cx.theme().colors().panel_indent_guide,
 27            hover: cx.theme().colors().panel_indent_guide_hover,
 28            active: cx.theme().colors().panel_indent_guide_active,
 29        }
 30    }
 31}
 32
 33pub struct IndentGuides {
 34    colors: IndentGuideColors,
 35    indent_size: Pixels,
 36    compute_indents_fn: Box<dyn Fn(Range<usize>, &mut WindowContext) -> SmallVec<[usize; 64]>>,
 37    render_fn: Option<
 38        Box<
 39            dyn Fn(
 40                RenderIndentGuideParams,
 41                &mut WindowContext,
 42            ) -> SmallVec<[RenderedIndentGuide; 12]>,
 43        >,
 44    >,
 45    on_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
 46}
 47
 48pub fn indent_guides<V: Render>(
 49    view: View<V>,
 50    indent_size: Pixels,
 51    colors: IndentGuideColors,
 52    compute_indents_fn: impl Fn(&mut V, Range<usize>, &mut ViewContext<V>) -> SmallVec<[usize; 64]>
 53        + 'static,
 54) -> IndentGuides {
 55    let compute_indents_fn = Box::new(move |range, cx: &mut WindowContext| {
 56        view.update(cx, |this, cx| compute_indents_fn(this, range, cx))
 57    });
 58    IndentGuides {
 59        colors,
 60        indent_size,
 61        compute_indents_fn,
 62        render_fn: None,
 63        on_click: None,
 64    }
 65}
 66
 67impl IndentGuides {
 68    /// Sets the callback that will be called when the user clicks on an indent guide.
 69    pub fn on_click(
 70        mut self,
 71        on_click: impl Fn(&IndentGuideLayout, &mut WindowContext) + 'static,
 72    ) -> Self {
 73        self.on_click = Some(Rc::new(on_click));
 74        self
 75    }
 76
 77    /// Sets a custom callback that will be called when the indent guides need to be rendered.
 78    pub fn with_render_fn<V: Render>(
 79        mut self,
 80        view: View<V>,
 81        render_fn: impl Fn(
 82                &mut V,
 83                RenderIndentGuideParams,
 84                &mut WindowContext,
 85            ) -> SmallVec<[RenderedIndentGuide; 12]>
 86            + 'static,
 87    ) -> Self {
 88        let render_fn = move |params, cx: &mut WindowContext| {
 89            view.update(cx, |this, cx| render_fn(this, params, cx))
 90        };
 91        self.render_fn = Some(Box::new(render_fn));
 92        self
 93    }
 94}
 95
 96/// Parameters for rendering indent guides.
 97pub struct RenderIndentGuideParams {
 98    /// The calculated layouts for the indent guides to be rendered.
 99    pub indent_guides: SmallVec<[IndentGuideLayout; 12]>,
100    /// The size of each indentation level in pixels.
101    pub indent_size: Pixels,
102    /// The height of each item in pixels.
103    pub item_height: Pixels,
104}
105
106/// Represents a rendered indent guide with its visual properties and interaction areas.
107pub struct RenderedIndentGuide {
108    /// The bounds of the rendered indent guide in pixels.
109    pub bounds: Bounds<Pixels>,
110    /// The layout information for the indent guide.
111    pub layout: IndentGuideLayout,
112    /// Indicates whether the indent guide is currently active.
113    pub is_active: bool,
114    /// Can be used to customize the hitbox of the indent guide,
115    /// if this is set to `None`, the bounds of the indent guide will be used.
116    pub hitbox: Option<Bounds<Pixels>>,
117}
118
119/// Represents the layout information for an indent guide.
120#[derive(Debug, PartialEq, Eq, Hash)]
121pub struct IndentGuideLayout {
122    /// The starting position of the indent guide, where x is the indentation level
123    /// and y is the starting row.
124    pub offset: Point<usize>,
125    /// The length of the indent guide in rows.
126    pub length: usize,
127    /// Indicates whether the indent guide continues beyond the visible bounds.
128    pub continues_offscreen: bool,
129}
130
131/// Implements the necessary functionality for rendering indent guides inside a uniform list.
132mod uniform_list {
133    use gpui::{DispatchPhase, Hitbox, MouseButton, MouseDownEvent, MouseMoveEvent};
134
135    use super::*;
136
137    impl UniformListDecoration for IndentGuides {
138        fn compute(
139            &self,
140            visible_range: Range<usize>,
141            bounds: Bounds<Pixels>,
142            item_height: Pixels,
143            item_count: usize,
144            cx: &mut WindowContext,
145        ) -> AnyElement {
146            let mut visible_range = visible_range.clone();
147            let includes_trailing_indent = visible_range.end < item_count;
148            // Check if we have entries after the visible range,
149            // if so extend the visible range so we can fetch a trailing indent,
150            // which is needed to compute indent guides correctly.
151            if includes_trailing_indent {
152                visible_range.end += 1;
153            }
154            let visible_entries = &(self.compute_indents_fn)(visible_range.clone(), cx);
155            let indent_guides = compute_indent_guides(
156                &visible_entries,
157                visible_range.start,
158                includes_trailing_indent,
159            );
160            let mut indent_guides = if let Some(ref custom_render) = self.render_fn {
161                let params = RenderIndentGuideParams {
162                    indent_guides,
163                    indent_size: self.indent_size,
164                    item_height,
165                };
166                custom_render(params, cx)
167            } else {
168                indent_guides
169                    .into_iter()
170                    .map(|layout| RenderedIndentGuide {
171                        bounds: Bounds::new(
172                            point(
173                                px(layout.offset.x as f32) * self.indent_size,
174                                px(layout.offset.y as f32) * item_height,
175                            ),
176                            size(px(1.), px(layout.length as f32) * item_height),
177                        ),
178                        layout,
179                        is_active: false,
180                        hitbox: None,
181                    })
182                    .collect()
183            };
184            for guide in &mut indent_guides {
185                guide.bounds.origin += bounds.origin;
186                if let Some(hitbox) = guide.hitbox.as_mut() {
187                    hitbox.origin += bounds.origin;
188                }
189            }
190
191            let indent_guides = IndentGuidesElement {
192                indent_guides: Rc::new(indent_guides),
193                colors: self.colors.clone(),
194                on_hovered_indent_guide_click: self.on_click.clone(),
195            };
196            indent_guides.into_any_element()
197        }
198    }
199
200    struct IndentGuidesElement {
201        colors: IndentGuideColors,
202        indent_guides: Rc<SmallVec<[RenderedIndentGuide; 12]>>,
203        on_hovered_indent_guide_click: Option<Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>>,
204    }
205
206    enum IndentGuidesElementPrepaintState {
207        Static,
208        Interactive {
209            hitboxes: Rc<SmallVec<[Hitbox; 12]>>,
210            on_hovered_indent_guide_click: Rc<dyn Fn(&IndentGuideLayout, &mut WindowContext)>,
211        },
212    }
213
214    impl Element for IndentGuidesElement {
215        type RequestLayoutState = ();
216        type PrepaintState = IndentGuidesElementPrepaintState;
217
218        fn id(&self) -> Option<ElementId> {
219            None
220        }
221
222        fn request_layout(
223            &mut self,
224            _id: Option<&gpui::GlobalElementId>,
225            cx: &mut WindowContext,
226        ) -> (gpui::LayoutId, Self::RequestLayoutState) {
227            (cx.request_layout(gpui::Style::default(), []), ())
228        }
229
230        fn prepaint(
231            &mut self,
232            _id: Option<&gpui::GlobalElementId>,
233            _bounds: Bounds<Pixels>,
234            _request_layout: &mut Self::RequestLayoutState,
235            cx: &mut WindowContext,
236        ) -> Self::PrepaintState {
237            if let Some(on_hovered_indent_guide_click) = self.on_hovered_indent_guide_click.clone()
238            {
239                let hitboxes = self
240                    .indent_guides
241                    .as_ref()
242                    .iter()
243                    .map(|guide| cx.insert_hitbox(guide.hitbox.unwrap_or(guide.bounds), false))
244                    .collect();
245                Self::PrepaintState::Interactive {
246                    hitboxes: Rc::new(hitboxes),
247                    on_hovered_indent_guide_click,
248                }
249            } else {
250                Self::PrepaintState::Static
251            }
252        }
253
254        fn paint(
255            &mut self,
256            _id: Option<&gpui::GlobalElementId>,
257            _bounds: Bounds<Pixels>,
258            _request_layout: &mut Self::RequestLayoutState,
259            prepaint: &mut Self::PrepaintState,
260            cx: &mut WindowContext,
261        ) {
262            match prepaint {
263                IndentGuidesElementPrepaintState::Static => {
264                    for indent_guide in self.indent_guides.as_ref() {
265                        let fill_color = if indent_guide.is_active {
266                            self.colors.active
267                        } else {
268                            self.colors.default
269                        };
270
271                        cx.paint_quad(fill(indent_guide.bounds, fill_color));
272                    }
273                }
274                IndentGuidesElementPrepaintState::Interactive {
275                    hitboxes,
276                    on_hovered_indent_guide_click,
277                } => {
278                    cx.on_mouse_event({
279                        let hitboxes = hitboxes.clone();
280                        let indent_guides = self.indent_guides.clone();
281                        let on_hovered_indent_guide_click = on_hovered_indent_guide_click.clone();
282                        move |event: &MouseDownEvent, phase, cx| {
283                            if phase == DispatchPhase::Bubble && event.button == MouseButton::Left {
284                                let mut active_hitbox_ix = None;
285                                for (i, hitbox) in hitboxes.iter().enumerate() {
286                                    if hitbox.is_hovered(cx) {
287                                        active_hitbox_ix = Some(i);
288                                        break;
289                                    }
290                                }
291
292                                let Some(active_hitbox_ix) = active_hitbox_ix else {
293                                    return;
294                                };
295
296                                let active_indent_guide = &indent_guides[active_hitbox_ix].layout;
297                                on_hovered_indent_guide_click(active_indent_guide, cx);
298
299                                cx.stop_propagation();
300                                cx.prevent_default();
301                            }
302                        }
303                    });
304                    let mut hovered_hitbox_id = None;
305                    for (i, hitbox) in hitboxes.iter().enumerate() {
306                        cx.set_cursor_style(gpui::CursorStyle::PointingHand, hitbox);
307                        let indent_guide = &self.indent_guides[i];
308                        let fill_color = if hitbox.is_hovered(cx) {
309                            hovered_hitbox_id = Some(hitbox.id);
310                            self.colors.hover
311                        } else if indent_guide.is_active {
312                            self.colors.active
313                        } else {
314                            self.colors.default
315                        };
316
317                        cx.paint_quad(fill(indent_guide.bounds, fill_color));
318                    }
319
320                    cx.on_mouse_event({
321                        let prev_hovered_hitbox_id = hovered_hitbox_id;
322                        let hitboxes = hitboxes.clone();
323                        move |_: &MouseMoveEvent, phase, cx| {
324                            let mut hovered_hitbox_id = None;
325                            for hitbox in hitboxes.as_ref() {
326                                if hitbox.is_hovered(cx) {
327                                    hovered_hitbox_id = Some(hitbox.id);
328                                    break;
329                                }
330                            }
331                            if phase == DispatchPhase::Capture {
332                                // If the hovered hitbox has changed, we need to re-paint the indent guides.
333                                match (prev_hovered_hitbox_id, hovered_hitbox_id) {
334                                    (Some(prev_id), Some(id)) => {
335                                        if prev_id != id {
336                                            cx.refresh();
337                                        }
338                                    }
339                                    (None, Some(_)) => {
340                                        cx.refresh();
341                                    }
342                                    (Some(_), None) => {
343                                        cx.refresh();
344                                    }
345                                    (None, None) => {}
346                                }
347                            }
348                        }
349                    });
350                }
351            }
352        }
353    }
354
355    impl IntoElement for IndentGuidesElement {
356        type Element = Self;
357
358        fn into_element(self) -> Self::Element {
359            self
360        }
361    }
362}
363
364fn compute_indent_guides(
365    indents: &[usize],
366    offset: usize,
367    includes_trailing_indent: bool,
368) -> SmallVec<[IndentGuideLayout; 12]> {
369    let mut indent_guides = SmallVec::<[IndentGuideLayout; 12]>::new();
370    let mut indent_stack = SmallVec::<[IndentGuideLayout; 8]>::new();
371
372    let mut min_depth = usize::MAX;
373    for (row, &depth) in indents.iter().enumerate() {
374        if includes_trailing_indent && row == indents.len() - 1 {
375            continue;
376        }
377
378        let current_row = row + offset;
379        let current_depth = indent_stack.len();
380        if depth < min_depth {
381            min_depth = depth;
382        }
383
384        match depth.cmp(&current_depth) {
385            Ordering::Less => {
386                for _ in 0..(current_depth - depth) {
387                    if let Some(guide) = indent_stack.pop() {
388                        indent_guides.push(guide);
389                    }
390                }
391            }
392            Ordering::Greater => {
393                for new_depth in current_depth..depth {
394                    indent_stack.push(IndentGuideLayout {
395                        offset: Point::new(new_depth, current_row),
396                        length: current_row,
397                        continues_offscreen: false,
398                    });
399                }
400            }
401            _ => {}
402        }
403
404        for indent in indent_stack.iter_mut() {
405            indent.length = current_row - indent.offset.y + 1;
406        }
407    }
408
409    indent_guides.extend(indent_stack);
410
411    for guide in indent_guides.iter_mut() {
412        if includes_trailing_indent
413            && guide.offset.y + guide.length == offset + indents.len().saturating_sub(1)
414        {
415            guide.continues_offscreen = indents
416                .last()
417                .map(|last_indent| guide.offset.x < *last_indent)
418                .unwrap_or(false);
419        }
420    }
421
422    indent_guides
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn test_compute_indent_guides() {
431        fn assert_compute_indent_guides(
432            input: &[usize],
433            offset: usize,
434            includes_trailing_indent: bool,
435            expected: Vec<IndentGuideLayout>,
436        ) {
437            use std::collections::HashSet;
438            assert_eq!(
439                compute_indent_guides(input, offset, includes_trailing_indent)
440                    .into_vec()
441                    .into_iter()
442                    .collect::<HashSet<_>>(),
443                expected.into_iter().collect::<HashSet<_>>(),
444            );
445        }
446
447        assert_compute_indent_guides(
448            &[0, 1, 2, 2, 1, 0],
449            0,
450            false,
451            vec![
452                IndentGuideLayout {
453                    offset: Point::new(0, 1),
454                    length: 4,
455                    continues_offscreen: false,
456                },
457                IndentGuideLayout {
458                    offset: Point::new(1, 2),
459                    length: 2,
460                    continues_offscreen: false,
461                },
462            ],
463        );
464
465        assert_compute_indent_guides(
466            &[2, 2, 2, 1, 1],
467            0,
468            false,
469            vec![
470                IndentGuideLayout {
471                    offset: Point::new(0, 0),
472                    length: 5,
473                    continues_offscreen: false,
474                },
475                IndentGuideLayout {
476                    offset: Point::new(1, 0),
477                    length: 3,
478                    continues_offscreen: false,
479                },
480            ],
481        );
482
483        assert_compute_indent_guides(
484            &[1, 2, 3, 2, 1],
485            0,
486            false,
487            vec![
488                IndentGuideLayout {
489                    offset: Point::new(0, 0),
490                    length: 5,
491                    continues_offscreen: false,
492                },
493                IndentGuideLayout {
494                    offset: Point::new(1, 1),
495                    length: 3,
496                    continues_offscreen: false,
497                },
498                IndentGuideLayout {
499                    offset: Point::new(2, 2),
500                    length: 1,
501                    continues_offscreen: false,
502                },
503            ],
504        );
505
506        assert_compute_indent_guides(
507            &[0, 1, 0],
508            0,
509            true,
510            vec![IndentGuideLayout {
511                offset: Point::new(0, 1),
512                length: 1,
513                continues_offscreen: false,
514            }],
515        );
516
517        assert_compute_indent_guides(
518            &[0, 1, 1],
519            0,
520            true,
521            vec![IndentGuideLayout {
522                offset: Point::new(0, 1),
523                length: 1,
524                continues_offscreen: true,
525            }],
526        );
527        assert_compute_indent_guides(
528            &[0, 1, 2],
529            0,
530            true,
531            vec![IndentGuideLayout {
532                offset: Point::new(0, 1),
533                length: 1,
534                continues_offscreen: true,
535            }],
536        );
537    }
538}