Skip to main content

ui/components/
indent_guides.rs

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