Skip to main content

gpui_kit_grid/state/
disclosure.rs

1//! Expanding and collapsing on screen: one mechanism for group captions and
2//! tree rows with children. It gives a tree row's cell its indent and
3//! chevron, finds a press on a chevron, runs the [`ExpandGesture`] and the
4//! keys (`ExpandRow`, `CollapseRow`, ←/→ on a caption, expand all, collapse
5//! all, captions only), toggles a line with everything below it, and moves
6//! the focus out of what a collapse hides. The tree's column is
7//! [`LiveGridState::tree_column`] (`state/tree.rs`), and `-` on a tree leaf
8//! or a collapsed tree row collapses its parent.
9//!
10//! The chevron follows the view's expansion, so it turns in the frame after
11//! the press; the lines follow when the expand job is done.
12
13use gpui_kit::accesskit::Action;
14use gpui_kit::component::{IconName, IconNamed as _};
15use gpui_kit::{
16    Context, InteractiveElement, MouseDownEvent, Pixels, Point, SharedString, Window, px,
17};
18
19use super::{
20    CELL_PADDING, ColumnOf, KeyOf, LiveGridDelegate, LiveGridState, SelectionOrigin,
21    a11y::RowContent,
22    events::{ExpandTarget, GroupOrigin},
23    frame::{LEAD_ICON_LANE, PaintedLine},
24    group_hash,
25    scroll::{AnchorLine, LineAnchor},
26    selection::{FocusedCell, LineTarget, Navigation, ShownRows},
27};
28use crate::{
29    actions::{CollapseAll, CollapseRow, ExpandAll, ExpandRow, ShowCaptionsOnly},
30    engine::{DisplayRow, ExpandDepth, GroupInfo, GroupPath, Projection},
31    model::GridRow,
32    policy::{ExpandArea, ExpandEdit, ExpandGesture, ExpandGestureContext},
33};
34
35/// The indent of one level of groups and of a tree.
36pub(super) const INDENT: Pixels = px(16.);
37
38/// The chevron's size: 12 px on `xsmall` and `small` rows, 14 px on
39/// `medium` and `large` ones.
40pub(super) fn chevron_size(row_height: Pixels) -> Pixels {
41    if row_height < px(32.) {
42        px(12.)
43    } else {
44        px(14.)
45    }
46}
47
48/// The paths of the chevrons, made once per frame.
49pub(super) struct Chevrons {
50    expanded: SharedString,
51    collapsed: SharedString,
52}
53
54impl Chevrons {
55    pub(super) fn new() -> Self {
56        Self {
57            expanded: IconName::ChevronDown.path(),
58            collapsed: IconName::ChevronRight.path(),
59        }
60    }
61
62    pub(super) fn path(&self, expanded: bool) -> SharedString {
63        if expanded {
64            self.expanded.clone()
65        } else {
66            self.collapsed.clone()
67        }
68    }
69}
70
71/// The selection origin of a focus move an expansion makes.
72fn selection_origin(origin: GroupOrigin) -> SelectionOrigin {
73    match origin {
74        GroupOrigin::User => SelectionOrigin::User,
75        GroupOrigin::Programmatic => SelectionOrigin::Programmatic,
76    }
77}
78
79/// The deepest group of a projection that holds the row at `order_ix` of
80/// `Projection::rows`, shown or not. Groups come in caption order, a group
81/// before its subgroups, so the last one holding the row is the deepest.
82/// O(groups): once per command or per shown order, never per frame.
83pub(super) fn row_group(projection: &Projection, order_ix: usize) -> Option<GroupInfo<'_>> {
84    projection
85        .groups()
86        .iter()
87        .filter(|group| {
88            let rows = group.rows();
89            rows.first()
90                .and_then(|first| projection.order_ix(*first))
91                .is_some_and(|start| (start..start + rows.len()).contains(&order_ix))
92        })
93        .last()
94}
95
96impl<D: LiveGridDelegate> LiveGridState<D> {
97    /// Sets how a press expands or collapses a group caption or a tree row
98    /// (default [`StandardExpandGesture`](crate::StandardExpandGesture): the
99    /// chevron toggles, with Alt/Option everything below it too, and a
100    /// double click on a caption toggles it).
101    pub fn with_expand_gesture(mut self, gesture: impl ExpandGesture) -> Self {
102        self.expand_gesture = Box::new(gesture);
103        self
104    }
105
106    /// The window x of the chevron lane of a caption at `depth`: the lanes
107    /// of every level and of a tree's rows line up from the body's left
108    /// edge.
109    pub(super) fn caption_lane_x(&self, depth: u16) -> Pixels {
110        self.body_bounds.origin.x + CELL_PADDING + INDENT * f32::from(depth)
111    }
112
113    /// The chevron, the caption or the tree cell under a window position,
114    /// with its painted line: a caption anywhere, a tree row with children
115    /// in its tree column's cell.
116    pub(super) fn disclosure_hit(
117        &self,
118        position: Point<Pixels>,
119    ) -> Option<(PaintedLine, ExpandArea)> {
120        let line = self.painted.get(self.line_at(position)?)?;
121        let lane = |lane_x: Pixels| (lane_x..lane_x + LEAD_ICON_LANE).contains(&position.x);
122        match line {
123            PaintedLine::Caption(path) => {
124                let area = if lane(self.caption_lane_x(path.depth())) {
125                    ExpandArea::Chevron
126                } else {
127                    ExpandArea::Caption
128                };
129                Some((line.clone(), area))
130            }
131            PaintedLine::Row(id) => {
132                let projection = &self.projection;
133                projection.branch(*id)?;
134                let column = self.tree_column()?;
135                if self.column_at_position(position) != Some(column) {
136                    return None;
137                }
138                let depth = projection.line(projection.display_ix(*id)?)?.depth();
139                let geometry = self
140                    .columns
141                    .geometry(self.body_bounds.size.width, self.scroll_x);
142                let (zone, i) = self.columns.zone_position(column)?;
143                let cell_x = self.body_bounds.origin.x
144                    + geometry
145                        .zone(zone)
146                        .column_x(self.columns.zone_columns(zone), i);
147                let area = if lane(cell_x + CELL_PADDING + INDENT * f32::from(depth)) {
148                    ExpandArea::Chevron
149                } else {
150                    ExpandArea::TreeCell
151                };
152                Some((line.clone(), area))
153            }
154            // A group footer has no disclosure.
155            PaintedLine::Footer(_) => None,
156            // A fading row has no children.
157            PaintedLine::Ghost(_) => None,
158        }
159    }
160
161    /// The expand target of a painted line.
162    fn line_expand_target(&self, line: &PaintedLine) -> Option<ExpandTarget<KeyOf<D>>> {
163        match line {
164            PaintedLine::Caption(path) => Some(ExpandTarget::Group(path.clone())),
165            PaintedLine::Row(id) => Some(ExpandTarget::Row(self.snapshot.row(*id)?.key().clone())),
166            // A group footer has no disclosure.
167            PaintedLine::Footer(_) => None,
168            // A fading row has no children.
169            PaintedLine::Ghost(_) => None,
170        }
171    }
172
173    /// A press on a caption or a tree row's chevron: the [`ExpandGesture`]
174    /// decides, and the line is toggled at once (`GroupOrigin::User`).
175    /// Returns true if it was.
176    pub(super) fn press_disclosure(
177        &mut self,
178        line: &PaintedLine,
179        area: ExpandArea,
180        event: &MouseDownEvent,
181        cx: &mut Context<Self>,
182    ) -> bool {
183        let context =
184            ExpandGestureContext::new(area, event.button, event.modifiers, event.click_count);
185        let Some(edit) = self.expand_gesture.edit(&context) else {
186            return false;
187        };
188        let Some(target) = self.line_expand_target(line) else {
189            return false;
190        };
191        self.anchor_toggled(line);
192        self.toggle_line(target, edit, GroupOrigin::User, cx)
193    }
194
195    /// Toggles a line: alone, or with every line below it (`ToggleDeep`).
196    fn toggle_line(
197        &mut self,
198        target: ExpandTarget<KeyOf<D>>,
199        edit: ExpandEdit,
200        origin: GroupOrigin,
201        cx: &mut Context<Self>,
202    ) -> bool {
203        let expanded = !self.is_expanded(&target);
204        match edit {
205            ExpandEdit::ToggleDeep => self.apply_deep_expanded(target, expanded, origin, cx),
206            _ => self.apply_expanded(target, expanded, origin, cx),
207        }
208    }
209
210    /// Expands or collapses a line and every line below it in the shown
211    /// projection: one `ExpansionChange`, with the line as its target.
212    pub(crate) fn apply_deep_expanded(
213        &mut self,
214        target: ExpandTarget<KeyOf<D>>,
215        expanded: bool,
216        origin: GroupOrigin,
217        cx: &mut Context<Self>,
218    ) -> bool {
219        let mut expansion = self.view.expansion().clone();
220        match &target {
221            ExpandTarget::Group(path) => {
222                if path.is_root() {
223                    return false;
224                }
225                let projection = self.projection.clone();
226                let below: Vec<GroupPath> = projection
227                    .groups()
228                    .iter()
229                    .filter(|group| group.path().starts_with(path) && group.path() != path)
230                    .map(|group| group.path().clone())
231                    .collect();
232                for path in std::iter::once(path.clone()).chain(below) {
233                    let levels = self.group_levels(&path);
234                    expansion = expansion.with_group(path, expanded, levels);
235                }
236            }
237            ExpandTarget::Row(key) => {
238                let (projection, snapshot) = (self.projection.clone(), self.snapshot.clone());
239                let Some(tree) = projection.tree_data() else {
240                    return false;
241                };
242                let Some((id, order_ix)) = snapshot
243                    .row_id(key)
244                    .and_then(|id| Some((id, projection.order_ix(id)?)))
245                else {
246                    return false;
247                };
248                let descendants = projection
249                    .branch(id)
250                    .map_or(0, |branch| branch.descendant_count());
251                for ix in order_ix..=order_ix + descendants {
252                    let Some(&row_id) = projection.rows().get(ix) else {
253                        break;
254                    };
255                    if projection.branch(row_id).is_none() {
256                        continue;
257                    }
258                    if let Some(row) = snapshot.row(row_id) {
259                        expansion = expansion.with_row(row.key().clone(), expanded, tree.depth(ix));
260                    }
261                }
262            }
263        }
264        self.apply_expansion(expansion, Some(target), origin, cx)
265    }
266
267    /// Keeps a toggled line on its screen line when the lines below it
268    /// come and go.
269    fn anchor_toggled(&mut self, line: &PaintedLine) {
270        let Some(screen) = self.painted.iter().position(|painted| painted == line) else {
271            return;
272        };
273        let anchor = match line {
274            PaintedLine::Caption(path) => AnchorLine::Group(path.clone()),
275            PaintedLine::Row(id) => AnchorLine::Row(*id),
276            // A group footer is never toggled.
277            PaintedLine::Footer(_) => return,
278            // A fading row is never toggled.
279            PaintedLine::Ghost(_) => return,
280        };
281        self.expand_anchor = Some(LineAnchor::new(anchor, screen, true));
282    }
283
284    /// Anchors the focused line, if painted, for a toggle from the keys.
285    fn anchor_focused(&mut self) {
286        let focused = self.selection.focus().and_then(|focus| {
287            Some(match (focus.row(), focus.group()) {
288                (Some(key), _) => PaintedLine::Row(self.snapshot.row_id(key)?),
289                (None, Some(path)) => PaintedLine::Caption(path.clone()),
290                (None, None) => return None,
291            })
292        });
293        if let Some(line) = focused {
294            self.anchor_toggled(&line);
295        }
296    }
297
298    /// ← and → on a focused caption (WAI-ARIA treegrid): ← collapses an
299    /// expanded caption, or moves to the enclosing one; → expands a
300    /// collapsed caption, or moves to the first line of the group.
301    pub(super) fn caption_arrow(&mut self, path: &GroupPath, right: bool, cx: &mut Context<Self>) {
302        let target = ExpandTarget::Group(path.clone());
303        let expanded = self.is_expanded(&target);
304        if right != expanded {
305            self.anchor_focused();
306            self.apply_expanded(target, right, GroupOrigin::User, cx);
307        } else if right {
308            self.navigate(Navigation::Down, false, cx);
309        } else if let Some(parent) = path.parent().filter(|parent| !parent.is_root()) {
310            let focused = self.edit_selection_focus(&parent, SelectionOrigin::User, cx);
311            if focused {
312                self.reveal_focus();
313                cx.notify();
314            }
315        }
316    }
317
318    /// `ExpandRow`: expands the focused caption or tree row with children;
319    /// otherwise the key goes on.
320    ///
321    /// On a cell that typing would edit, `+` is the draft's text: the key
322    /// goes on to typing.
323    fn on_expand_row(&mut self, _: &ExpandRow, _: &mut Window, cx: &mut Context<Self>) {
324        if self.would_type_into_focus("+", cx) {
325            cx.propagate();
326            return;
327        }
328        let target = match self.focus_target() {
329            Some(LineTarget::Group(path)) => Some(ExpandTarget::Group(path)),
330            Some(LineTarget::Row(key)) if self.is_tree_branch(&key) => Some(ExpandTarget::Row(key)),
331            _ => None,
332        };
333        match target.filter(|target| !self.is_expanded(target)) {
334            Some(target) => {
335                self.anchor_focused();
336                self.apply_expanded(target, true, GroupOrigin::User, cx);
337            }
338            None => cx.propagate(),
339        }
340    }
341
342    /// `CollapseRow`: collapses the focused caption or expanded tree row with
343    /// children; on a data row of a grouped view, its group, and the focus
344    /// goes to the group's caption (as Excel's outline); on a tree leaf or
345    /// a collapsed tree row, its parent, and the focus goes to the parent
346    /// (WAI-ARIA treegrid's "move to parent", on `-` because ← moves
347    /// between cells). Otherwise, on a root for one, the key goes on.
348    ///
349    /// On a cell that typing would edit, `-` is the draft's text: the key
350    /// goes on to typing, so `-5` typed into a quantity does not collapse
351    /// the group.
352    fn on_collapse_row(&mut self, _: &CollapseRow, _: &mut Window, cx: &mut Context<Self>) {
353        if self.would_type_into_focus("-", cx) {
354            cx.propagate();
355            return;
356        }
357        let target = match self.focus_target() {
358            Some(LineTarget::Group(path)) => Some(ExpandTarget::Group(path)),
359            Some(LineTarget::Row(key)) if self.projection.is_tree() => {
360                let branch = ExpandTarget::Row(key.clone());
361                if self.is_tree_branch(&key) && self.is_expanded(&branch) {
362                    Some(branch)
363                } else {
364                    self.tree_parent(&key).map(ExpandTarget::Row)
365                }
366            }
367            Some(LineTarget::Row(key)) => self
368                .snapshot
369                .row_id(&key)
370                .and_then(|id| self.projection.order_ix(id))
371                .and_then(|order_ix| row_group(&self.projection, order_ix))
372                .map(|group| ExpandTarget::Group(group.path().clone())),
373            None => None,
374        };
375        match target.filter(|target| self.is_expanded(target)) {
376            Some(target) => {
377                let line = match &target {
378                    ExpandTarget::Group(path) => Some(PaintedLine::Caption(path.clone())),
379                    ExpandTarget::Row(key) => self.snapshot.row_id(key).map(PaintedLine::Row),
380                };
381                if let Some(line) = line {
382                    self.anchor_toggled(&line);
383                }
384                self.apply_expanded(target, false, GroupOrigin::User, cx);
385            }
386            None => cx.propagate(),
387        }
388    }
389
390    /// `ExpandAll`, `CollapseAll`, `ShowCaptionsOnly`: a new default depth,
391    /// every toggle cleared.
392    fn set_depth_from_user(&mut self, depth: ExpandDepth, cx: &mut Context<Self>) {
393        self.apply_expand_depth(depth, GroupOrigin::User, cx);
394    }
395
396    /// The keyboard Actions of expansion, on the grid root.
397    pub(super) fn bind_disclosure_actions<E: InteractiveElement>(root: E, cx: &Context<Self>) -> E {
398        root.on_action(cx.listener(Self::on_expand_row))
399            .on_action(cx.listener(Self::on_collapse_row))
400            .on_action(cx.listener(|this, _: &ExpandAll, _, cx| {
401                this.set_depth_from_user(ExpandDepth::All, cx)
402            }))
403            .on_action(cx.listener(|this, _: &CollapseAll, _, cx| {
404                this.set_depth_from_user(ExpandDepth::Levels(0), cx)
405            }))
406            .on_action(cx.listener(|this, _: &ShowCaptionsOnly, _, cx| {
407                this.set_depth_from_user(ExpandDepth::Captions, cx)
408            }))
409    }
410
411    /// A screen reader asks the row element with `key_hash` (a caption's
412    /// group hash, a tree row's key hash) to expand or collapse
413    /// (`on_a11y_action`), as its chevron would.
414    pub(crate) fn on_a11y_disclosure(
415        &mut self,
416        content: RowContent,
417        key_hash: u64,
418        action: Action,
419        cx: &mut Context<Self>,
420    ) {
421        let line = self.painted.iter().find(|line| match (line, content) {
422            (PaintedLine::Caption(path), RowContent::Caption) => group_hash(path) == key_hash,
423            (PaintedLine::Row(id), RowContent::Data) => self
424                .snapshot
425                .row(*id)
426                .is_some_and(|row| super::key_hash(row.key()) == key_hash),
427            _ => false,
428        });
429        let Some(line) = line.cloned() else {
430            return;
431        };
432        let Some(target) = self.line_expand_target(&line) else {
433            return;
434        };
435        let expanded = match action {
436            Action::Expand => true,
437            Action::Collapse => false,
438            _ => return,
439        };
440        if self.is_expanded(&target) != expanded {
441            self.anchor_toggled(&line);
442            self.apply_expanded(target, expanded, GroupOrigin::User, cx);
443        }
444    }
445
446    /// Sends a screen reader's `action` to the accessibility row element
447    /// with `row_id` (`NamedInteger("live-grid-group-row", group hash)` of
448    /// a caption, `NamedInteger("live-grid-row", key hash)` of a tree row),
449    /// as GPUI does through the element's `on_a11y_action`, which a headless
450    /// window never calls. For tests.
451    #[cfg(any(test, feature = "test-support"))]
452    pub fn send_a11y_action(
453        &mut self,
454        row_id: &gpui_kit::ElementId,
455        action: Action,
456        cx: &mut Context<Self>,
457    ) {
458        let gpui_kit::ElementId::NamedInteger(name, key_hash) = row_id else {
459            return;
460        };
461        let content = match name.as_ref() {
462            "live-grid-group-row" => RowContent::Caption,
463            "live-grid-row" => RowContent::Data,
464            _ => return,
465        };
466        self.on_a11y_disclosure(content, *key_hash, action, cx);
467    }
468
469    /// Moves the focus out of what the view's expansion hides, at once,
470    /// before the lines are laid out again: a focused row or caption under a
471    /// collapsed group or tree row moves to the caption or row of the
472    /// outermost collapsed one, the deepest line still shown. The
473    /// `FocusChange` follows the `ExpansionChange`, with the same origin.
474    pub(super) fn focus_off_collapsed(&mut self, origin: GroupOrigin, cx: &mut Context<Self>) {
475        let Some(target) = self.collapsed_ancestor() else {
476            return;
477        };
478        let mark = self.selection.mark();
479        let column = self.selection.focus().map(|focus| focus.column());
480        if let Some(column) = column {
481            self.selection.focus_on(match target {
482                LineTarget::Group(path) => FocusedCell::caption(path, column),
483                LineTarget::Row(key) => FocusedCell::new(key, column),
484            });
485        }
486        self.finish_change(mark, selection_origin(origin), cx);
487    }
488
489    /// The outermost collapsed group or tree row, by the view's expansion,
490    /// above the focused line in the shown projection; `None` if nothing
491    /// above it is collapsed.
492    fn collapsed_ancestor(&self) -> Option<LineTarget<KeyOf<D>>> {
493        let focus = self.selection.focus()?;
494        let (projection, snapshot) = (&self.projection, &self.snapshot);
495        let expansion = self.view.expansion();
496        if let Some(path) = focus.group() {
497            let group = projection.groups().find(path)?;
498            return self
499                .outermost_collapsed(group.parent().and_then(|id| projection.groups().get(id)));
500        }
501        let id = snapshot.row_id(focus.row()?)?;
502        if projection.is_grouped() {
503            let group = row_group(projection, projection.order_ix(id)?)?;
504            return self.outermost_collapsed(Some(group));
505        }
506        if !projection.is_tree() {
507            return None;
508        }
509        // The tree ancestors the projection holds, nearest first.
510        let mut ancestors: Vec<KeyOf<D>> = Vec::new();
511        let mut row = snapshot.row(id)?;
512        while ancestors.len() < usize::from(u16::MAX) {
513            let Some(parent) = row
514                .parent_key()
515                .and_then(|parent| snapshot.row_id(parent))
516                .filter(|parent| projection.contains(*parent))
517            else {
518                break;
519            };
520            row = snapshot.row(parent)?;
521            ancestors.push(row.key().clone());
522        }
523        ancestors
524            .iter()
525            .rev()
526            .enumerate()
527            .find(|(depth, key)| !expansion.is_row_expanded(key, *depth as u16))
528            .map(|(_, key)| LineTarget::Row(key.clone()))
529    }
530
531    /// The outermost collapsed group from `group` up, by the view's
532    /// expansion.
533    fn outermost_collapsed(&self, group: Option<GroupInfo<'_>>) -> Option<LineTarget<KeyOf<D>>> {
534        let projection = &self.projection;
535        let expansion = self.view.expansion();
536        let mut collapsed = None;
537        let mut group = group;
538        while let Some(info) = group {
539            let path = info.path();
540            if !expansion.is_group_expanded(path, self.group_levels(path)) {
541                collapsed = Some(path.clone());
542            }
543            group = info.parent().and_then(|id| projection.groups().get(id));
544        }
545        collapsed.map(LineTarget::Group)
546    }
547
548    /// Keeps the focus on a line the newly shown projection shows, after
549    /// `ProjectionChange`: a focused caption whose group is gone goes to the
550    /// line at its place, else the nearest one above; a focused row a
551    /// rebuild moved under a collapsed group or tree row goes to the caption
552    /// or row of the outermost collapsed one. Both are `Filtered`; the
553    /// caller emits the events.
554    pub(super) fn follow_shown_lines(&mut self, previous: &Projection) {
555        let Some(focus) = self.selection.focus() else {
556            return;
557        };
558        let column = focus.column();
559        if let Some(path) = focus.group() {
560            if let Some(group) = self.projection.groups().find(path) {
561                if self.projection.group_line(group.id()).is_some() {
562                    return;
563                }
564            } else {
565                let place = previous
566                    .groups()
567                    .find(path)
568                    .and_then(|group| previous.group_line(group.id()))
569                    .unwrap_or(0);
570                if let Some(cell) = self.focusable_at_or_above(place, column) {
571                    self.selection.focus_on(cell);
572                    return;
573                }
574            }
575        } else {
576            let shown = focus
577                .row()
578                .and_then(|key| self.snapshot.row_id(key))
579                .map(|id| {
580                    self.projection.display_ix(id).is_some() || !self.projection.contains(id)
581                });
582            if shown != Some(false) {
583                return;
584            }
585        }
586        if let Some(target) = self.collapsed_ancestor() {
587            self.selection.focus_on(match target {
588                LineTarget::Group(path) => FocusedCell::caption(path, column),
589                LineTarget::Row(key) => FocusedCell::new(key, column),
590            });
591        }
592    }
593
594    /// The line at `place` of the shown projection that takes the focus,
595    /// else the nearest one above, else below.
596    fn focusable_at_or_above(
597        &self,
598        place: usize,
599        column: ColumnOf<D>,
600    ) -> Option<FocusedCell<KeyOf<D>, ColumnOf<D>>> {
601        let (projection, snapshot) = (&self.projection, &self.snapshot);
602        let len = projection.len();
603        let place = place.min(len.checked_sub(1)?);
604        let cell_at = |ix: usize| -> Option<FocusedCell<KeyOf<D>, ColumnOf<D>>> {
605            match projection.line(ix)? {
606                DisplayRow::Data { row, .. } => {
607                    Some(FocusedCell::new(snapshot.row(row)?.key().clone(), column))
608                }
609                DisplayRow::GroupCaption { group, .. } => Some(FocusedCell::caption(
610                    projection.groups().get(group)?.path().clone(),
611                    column,
612                )),
613                _ => None,
614            }
615        };
616        (0..=place)
617            .rev()
618            .find_map(cell_at)
619            .or_else(|| (place + 1..len).find_map(cell_at))
620    }
621
622    /// Focuses the caption at `path`, if shown; returns true if it did.
623    fn edit_selection_focus(
624        &mut self,
625        path: &GroupPath,
626        origin: SelectionOrigin,
627        cx: &mut Context<Self>,
628    ) -> bool {
629        let mark = self.selection.mark();
630        let (projection, snapshot) = (self.projection.clone(), self.snapshot.clone());
631        let rows = ShownRows::new(&projection, &snapshot);
632        let focused = self.selection.focus_caption(path, rows, &self.columns);
633        self.finish_change(mark, origin, cx);
634        focused
635    }
636}