Skip to main content

gpui_kit_material/components/
focus.rs

1//! Focus presentation for composed controls, and scrolling that reveals
2//! newly focused rows without pinning them during manual scrolling.
3use super::InteractionPreview;
4use super::button::ButtonFocusRing;
5use crate::tokens::{shape::RADIUS_FULL, typography::rem};
6use gpui::{prelude::*, *};
7
8/// Draw the M3 keyboard focus indicator (the button's 3dp secondary ring, 2dp
9/// outside) around a component whose focus handle is internal. The wrapper
10/// observes descendants without adding a keyboard stop of its own, and shows
11/// the ring only for keyboard focus.
12#[derive(gpui::IntoElement)]
13pub struct FocusRing {
14    id: ElementId,
15    child: AnyElement,
16}
17
18impl FocusRing {
19    pub fn new(id: impl Into<ElementId>, child: impl IntoElement) -> Self {
20        Self {
21            id: id.into(),
22            child: child.into_any_element(),
23        }
24    }
25}
26
27struct RingState {
28    container: FocusHandle,
29    active: bool,
30    /// The window focus when containment was last resolved.
31    seen: Option<FocusHandle>,
32}
33
34impl RenderOnce for FocusRing {
35    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
36        let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| RingState {
37            container: cx.focus_handle(),
38            active: false,
39            seen: None,
40        });
41        let (focus, active) = (state.read(cx).container.clone(), state.read(cx).active);
42        let focused = window.focused(cx);
43        // Containment is only reliable after the frame commits, so resolve it
44        // then, and only when window focus has moved since the last check.
45        if state.read(cx).seen != focused {
46            let observed = state.clone();
47            window.defer(cx, move |window, cx| {
48                let active = observed.read(cx).container.contains_focused(window, cx);
49                let changed = active != observed.read(cx).active;
50                observed.update(cx, |state, _| {
51                    state.active = active;
52                    state.seen = focused;
53                });
54                if changed {
55                    window.refresh();
56                }
57            });
58        }
59        let wrapper = div()
60            .id(self.id)
61            .track_focus(&focus)
62            .tab_stop(false)
63            .flex_shrink_0()
64            .rounded_full()
65            .child(self.child);
66        ButtonFocusRing::resolved(wrapper, active, rem(RADIUS_FULL.as_f32()))
67    }
68}
69
70/// How a [`RingedButton`] draws its keyboard focus ring.
71#[derive(Clone, Copy, Debug, PartialEq)]
72pub(crate) enum RingPlacement {
73    /// The M3 outer offset, for standalone controls.
74    Outer(Corners<Pixels>),
75    /// The M3 inner offset, for items packed edge to edge with neighbours.
76    Inner(Corners<Pixels>),
77}
78
79/// A GPUI Base button that draws the shared M3 keyboard focus ring. It
80/// forwards the builder methods callers chain on a button, and resolves its
81/// focus handle when it renders.
82#[derive(gpui::IntoElement)]
83pub struct RingedButton {
84    id: ElementId,
85    button: gpui_base::Button,
86    focus: Option<FocusHandle>,
87    placement: RingPlacement,
88    disabled: bool,
89    /// Hover and press state-layer backgrounds, installed as live styles or
90    /// painted directly for a forced preview.
91    layers: Option<(Rgba, Rgba)>,
92    preview: Option<InteractionPreview>,
93}
94
95impl RingedButton {
96    pub(crate) fn new(id: impl Into<ElementId>, placement: RingPlacement) -> Self {
97        let id = id.into();
98        Self {
99            button: gpui_base::Button::new(id.clone()),
100            id,
101            focus: None,
102            placement,
103            disabled: false,
104            layers: None,
105            preview: None,
106        }
107    }
108
109    /// Restyle the inner button.
110    pub(crate) fn map(mut self, f: impl FnOnce(gpui_base::Button) -> gpui_base::Button) -> Self {
111        self.button = f(self.button);
112        self
113    }
114
115    /// The container under its hover and press state layers. Live, they are
116    /// the pointer hover and active styles; a preview paints the one it forces.
117    pub(crate) fn state_layers(mut self, hover: Rgba, press: Rgba) -> Self {
118        self.layers = Some((hover, press));
119        self
120    }
121
122    /// Render a static specimen in one forced interaction state: the hover or
123    /// press layer, the focus ring, or the disabled control. The specimen
124    /// leaves the tab order and ignores live hover and press. See
125    /// [`InteractionPreview`].
126    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
127        self.preview = Some(state);
128        if state.is_disabled() {
129            self = self.disabled(true);
130        }
131        self
132    }
133
134    /// Use a caller-owned focus handle, e.g. for roving focus in a group.
135    pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
136        self.focus = Some(focus.clone());
137        self
138    }
139
140    /// The activation handler for pointer, Enter and Space input.
141    pub fn on_click(self, handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self {
142        self.map(|button| button.on_click(handler))
143    }
144
145    pub fn disabled(mut self, disabled: bool) -> Self {
146        self.disabled = disabled;
147        self.map(|button| button.disabled(disabled))
148    }
149
150    pub fn accessibility_label(self, label: impl Into<SharedString>) -> Self {
151        self.map(|button| button.accessibility_label(label))
152    }
153
154    pub fn tab_stop(self, tab_stop: bool) -> Self {
155        self.map(|button| button.tab_stop(tab_stop))
156    }
157}
158
159impl Styled for RingedButton {
160    fn style(&mut self) -> &mut StyleRefinement {
161        self.button.style()
162    }
163}
164
165impl ParentElement for RingedButton {
166    fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
167        self.button.extend(elements)
168    }
169}
170
171impl InteractiveElement for RingedButton {
172    fn interactivity(&mut self) -> &mut Interactivity {
173        self.button.interactivity()
174    }
175}
176
177impl StatefulInteractiveElement for RingedButton {}
178
179impl RenderOnce for RingedButton {
180    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
181        let focus = self.focus.unwrap_or_else(|| {
182            window
183                .use_keyed_state(
184                    ElementId::NamedChild(self.id.clone().into(), "focus".into()),
185                    cx,
186                    |_, cx| cx.focus_handle(),
187                )
188                .read(cx)
189                .clone()
190        });
191        #[cfg(test)]
192        let key = format!("{:?}", self.id);
193        let enabled = !self.disabled;
194        // A disabled button is inert and a specimen is not operated: neither
195        // shows the pointer cursor (nor, below, live state layers).
196        let pointer = shows_pointer(enabled, self.preview);
197        let mut button = self.button.track_focus(&focus).map(|button| {
198            if pointer {
199                button.cursor_pointer()
200            } else {
201                button.cursor_default()
202            }
203        });
204        if let Some((hover, press)) = self.layers.filter(|_| enabled) {
205            button = match self.preview {
206                None => button
207                    .hover(move |style| style.bg(hover))
208                    .active(move |style| style.bg(press)),
209                Some(InteractionPreview::Hovered) => button.bg(hover),
210                Some(InteractionPreview::Pressed) => button.bg(press),
211                Some(_) => button,
212            };
213        }
214        if self.preview.is_some() {
215            button = button.tab_stop(false);
216        }
217        let forced = self.preview == Some(InteractionPreview::Focused);
218        let ring = match self.placement {
219            RingPlacement::Outer(corners) => {
220                ButtonFocusRing::with_corners(button, focus, enabled, corners)
221            }
222            RingPlacement::Inner(corners) => {
223                ButtonFocusRing::with_corners(button, focus, enabled, corners).inward()
224            }
225        }
226        .forced(forced);
227        #[cfg(test)]
228        let ring = ring.observe(test_rings::cell(key));
229        ring
230    }
231}
232
233/// Whether a [`RingedButton`] shows the pointer cursor and live hover and
234/// press layers: only when it is enabled and not a forced specimen.
235pub(crate) fn shows_pointer(enabled: bool, preview: Option<InteractionPreview>) -> bool {
236    enabled && preview.is_none()
237}
238
239/// Test hook: the focus ring each [`RingedButton`] last painted, by the
240/// debug form of its element id.
241#[cfg(test)]
242pub(crate) mod test_rings {
243    use gpui::{Bounds, Pixels};
244    use std::{cell::Cell, cell::RefCell, collections::HashMap, rc::Rc};
245    type Ring = Rc<Cell<Option<Bounds<Pixels>>>>;
246    thread_local! {
247        static RINGS: RefCell<HashMap<String, Ring>> = RefCell::new(HashMap::new());
248    }
249    pub(crate) fn cell(key: String) -> Ring {
250        RINGS.with(|rings| rings.borrow_mut().entry(key).or_default().clone())
251    }
252    /// The ring painted for a button whose id's debug form contains `part`.
253    pub(crate) fn painted(part: &str) -> Option<Bounds<Pixels>> {
254        RINGS.with(|rings| {
255            rings
256                .borrow()
257                .iter()
258                .filter(|(key, _)| key.contains(part))
259                .find_map(|(_, ring)| ring.get())
260        })
261    }
262}
263
264#[derive(Clone)]
265enum RevealScroll {
266    Handle(ScrollHandle),
267    List(ListState),
268}
269
270impl RevealScroll {
271    fn viewport(&self) -> Bounds<Pixels> {
272        match self {
273            RevealScroll::Handle(scroll) => scroll.bounds(),
274            RevealScroll::List(state) => state.viewport_bounds(),
275        }
276    }
277
278    fn scroll_by(&self, delta: Pixels) {
279        match self {
280            RevealScroll::Handle(scroll) => {
281                scroll.set_offset(scroll.offset() + point(px(0.), delta));
282            }
283            // Reveal deltas move the content; ListState distances move the
284            // viewport through the content, so their signs are opposite.
285            RevealScroll::List(state) => state.scroll_by(-delta),
286        }
287    }
288}
289
290/// Scrolls `scroll` so a newly focused descendant is visible, once per focus
291/// change or geometry change, never while the person scrolls by hand.
292#[derive(gpui::IntoElement)]
293pub struct RevealFocus {
294    id: ElementId,
295    child: AnyElement,
296    scroll: RevealScroll,
297    full_width: bool,
298}
299
300struct State {
301    container: FocusHandle,
302    focused: Option<FocusHandle>,
303    /// Window focus and geometry when the reveal was last resolved.
304    seen: Option<(Option<FocusHandle>, Pixels, Size<Pixels>)>,
305}
306
307impl RevealFocus {
308    pub fn new(id: impl Into<ElementId>, child: impl IntoElement, scroll: ScrollHandle) -> Self {
309        Self {
310            id: id.into(),
311            child: child.into_any_element(),
312            scroll: RevealScroll::Handle(scroll),
313            full_width: true,
314        }
315    }
316
317    /// Same reveal behavior against a variable-height `list` viewport.
318    pub fn in_list(id: impl Into<ElementId>, child: impl IntoElement, list: ListState) -> Self {
319        Self {
320            id: id.into(),
321            child: child.into_any_element(),
322            scroll: RevealScroll::List(list),
323            full_width: true,
324        }
325    }
326
327    pub fn inline(mut self) -> Self {
328        self.full_width = false;
329        self
330    }
331}
332
333impl RenderOnce for RevealFocus {
334    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
335        let state = window.use_keyed_state(self.id.clone(), cx, |_, cx| State {
336            container: cx.focus_handle(),
337            focused: None,
338            seen: None,
339        });
340        let focus = state.read(cx).container.clone();
341        div()
342            .on_children_prepainted(move |bounds, window, cx| {
343                let Some(bounds) = bounds.first().copied() else {
344                    return;
345                };
346                let seen = (window.focused(cx), window.rem_size(), window.bounds().size);
347                if state.read(cx).seen.as_ref() == Some(&seen) {
348                    return;
349                }
350                let state = state.clone();
351                let scroll = self.scroll.clone();
352                // Focus ancestry is only reliable after this frame has committed:
353                // prepaint moves reused subtrees out of the previous dispatch tree.
354                window.defer(cx, move |window, cx| {
355                    let focused = window
356                        .focused(cx)
357                        .filter(|_| state.read(cx).container.contains_focused(window, cx));
358                    let geometry_changed = state
359                        .read(cx)
360                        .seen
361                        .as_ref()
362                        .is_none_or(|(_, rem, size)| (*rem, *size) != (seen.1, seen.2));
363                    state.update(cx, |state, _| {
364                        let reveal =
365                            focused.is_some() && (state.focused != focused || geometry_changed);
366                        state.focused = focused;
367                        state.seen = Some(seen);
368                        if !reveal {
369                            return;
370                        }
371                        let viewport = scroll.viewport();
372                        if viewport.size.height <= px(0.) {
373                            return;
374                        }
375                        let delta = reveal_delta(
376                            f32::from(bounds.top()),
377                            f32::from(bounds.bottom()),
378                            f32::from(viewport.top()) + 8.,
379                            f32::from(viewport.bottom()) - 8.,
380                        );
381                        if delta != 0. {
382                            scroll.scroll_by(px(delta));
383                            window.refresh();
384                        }
385                    });
386                });
387            })
388            .id(self.id)
389            .track_focus(&focus)
390            .tab_stop(false)
391            .flex_shrink_0()
392            .when(self.full_width, |view| view.w_full())
393            .min_w_0()
394            .child(self.child)
395    }
396}
397
398/// A scroll area inside a scrolling page keeps the wheel while it has content
399/// to scroll, so the page does not move with it. Its own scroll listener runs
400/// first, then this one stops the event before it reaches the page.
401pub fn contain_scroll(view: Stateful<Div>, handle: &ScrollHandle) -> Stateful<Div> {
402    let observed = handle.clone();
403    view.track_scroll(handle).on_scroll(move |_, _, cx| {
404        if observed.max_offset().y > px(0.) {
405            cx.stop_propagation();
406        }
407    })
408}
409
410fn reveal_delta(top: f32, bottom: f32, visible_top: f32, visible_bottom: f32) -> f32 {
411    if top < visible_top || bottom - top > visible_bottom - visible_top {
412        visible_top - top
413    } else if bottom > visible_bottom {
414        visible_bottom - bottom
415    } else {
416        0.
417    }
418}
419
420#[cfg(test)]
421mod tests {
422    use super::{RevealFocus, reveal_delta};
423    use gpui::{
424        Context, FocusHandle, IntoElement, ListAlignment, ListOffset, ListState, Render,
425        TestAppContext, Window, div, list, prelude::*, px, size,
426    };
427
428    struct ListRevealHarness {
429        list: ListState,
430        focus: FocusHandle,
431        inline: bool,
432    }
433
434    impl Render for ListRevealHarness {
435        fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
436            let state = self.list.clone();
437            let focus = self.focus.clone();
438            let inline = self.inline;
439            list(self.list.clone(), move |_, _, _| {
440                let control = div()
441                    .id("focused-control")
442                    .debug_selector(|| "list-reveal-control".into())
443                    .track_focus(&focus)
444                    .w(px(40.))
445                    .h(px(30.));
446                let reveal = RevealFocus::in_list("list-reveal", control, state.clone());
447                let reveal = if inline { reveal.inline() } else { reveal };
448                div()
449                    .relative()
450                    .w_full()
451                    .h(px(400.))
452                    .child(div().absolute().top(px(150.)).w_full().child(reveal))
453                    .into_any_element()
454            })
455            .w(px(100.))
456            .h(px(100.))
457        }
458    }
459
460    #[gpui::test]
461    fn list_focus_reveal_scrolls_both_edges_in_content_coordinates(cx: &mut TestAppContext) {
462        for inline in [false, true] {
463            for initial_offset in [0., 170.] {
464                let (view, cx) = cx.add_window_view(|_, cx| {
465                    let state = ListState::new(1, ListAlignment::Top, px(0.));
466                    state.scroll_to(ListOffset {
467                        item_ix: 0,
468                        offset_in_item: px(initial_offset),
469                    });
470                    ListRevealHarness {
471                        list: state,
472                        focus: cx.focus_handle(),
473                        inline,
474                    }
475                });
476                cx.run_until_parked();
477                let before = cx.debug_bounds("list-reveal-control").unwrap();
478                cx.update(|window, cx| {
479                    let focus = view.read(cx).focus.clone();
480                    window.focus(&focus, cx);
481                    window.refresh();
482                });
483                cx.run_until_parked();
484                let after = cx.debug_bounds("list-reveal-control").unwrap();
485                let viewport = cx.update(|_, cx| view.read(cx).list.viewport_bounds());
486                if initial_offset == 0. {
487                    assert!(before.bottom() > viewport.bottom());
488                    assert!(after.top() < before.top(), "bottom reveal must scroll down");
489                } else {
490                    assert!(before.top() < viewport.top());
491                    assert!(after.top() > before.top(), "top reveal must scroll up");
492                }
493                assert!(after.top() >= viewport.top() + px(8.));
494                assert!(after.bottom() <= viewport.bottom() - px(8.));
495                assert_eq!(after.size, size(px(40.), px(30.)));
496            }
497        }
498    }
499
500    #[test]
501    fn reveals_clipped_controls_and_the_start_of_oversized_rows() {
502        assert_eq!(reveal_delta(100., 160., 80., 400.), 0.);
503        assert_eq!(reveal_delta(30., 100., 80., 400.), 50.);
504        assert_eq!(reveal_delta(380., 440., 80., 400.), -40.);
505        assert_eq!(reveal_delta(300., 900., 80., 400.), -220.);
506    }
507}