1use super::InteractionPreview;
4use super::button::ButtonFocusRing;
5use crate::tokens::{shape::RADIUS_FULL, typography::rem};
6use gpui::{prelude::*, *};
7
8#[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 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 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#[derive(Clone, Copy, Debug, PartialEq)]
72pub(crate) enum RingPlacement {
73 Outer(Corners<Pixels>),
75 Inner(Corners<Pixels>),
77}
78
79#[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 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 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 pub(crate) fn state_layers(mut self, hover: Rgba, press: Rgba) -> Self {
118 self.layers = Some((hover, press));
119 self
120 }
121
122 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 pub fn track_focus(mut self, focus: &FocusHandle) -> Self {
136 self.focus = Some(focus.clone());
137 self
138 }
139
140 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 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
233pub(crate) fn shows_pointer(enabled: bool, preview: Option<InteractionPreview>) -> bool {
236 enabled && preview.is_none()
237}
238
239#[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 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 RevealScroll::List(state) => state.scroll_by(-delta),
286 }
287 }
288}
289
290#[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 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 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 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
398pub 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}