Skip to main content

cranpose_ui/modifier/
focus_ring.rs

1//! The ring a control shows while the keyboard holds focus on it, so a person
2//! who works without a pointer sees where the next Enter lands.
3
4use std::{cell::Cell, rc::Rc};
5
6use cranpose_ui_graphics::{Brush, Color, DrawScope, Rect, Size, Stroke};
7
8use super::Modifier;
9use crate::focus_dispatch::keyboard_focus_visible;
10
11/// The outer line of the ring: a blue that stands out on white and on black.
12pub const FOCUS_RING_COLOR: Color = Color::from_rgba_u8(0, 95, 204, 255);
13/// The inner line of the ring, so the ring stands out on a blue control too.
14pub const FOCUS_RING_INNER_COLOR: Color = Color::from_rgba_u8(255, 255, 255, 255);
15const OUTER_WIDTH: f32 = 2.0;
16const INNER_WIDTH: f32 = 1.0;
17
18/// The two lines of a ring around content of the given size, outer line
19/// first, each with its stroke width. Both lines sit inside the content
20/// bounds, so the ring never leaves the control's own area.
21pub fn focus_ring_lines(size: Size) -> [(Rect, f32); 2] {
22    [
23        (inset(size, OUTER_WIDTH / 2.0), OUTER_WIDTH),
24        (inset(size, OUTER_WIDTH + INNER_WIDTH / 2.0), INNER_WIDTH),
25    ]
26}
27
28fn inset(size: Size, by: f32) -> Rect {
29    Rect {
30        x: by,
31        y: by,
32        width: (size.width - 2.0 * by).max(0.0),
33        height: (size.height - 2.0 * by).max(0.0),
34    }
35}
36
37fn draw_focus_ring(scope: &mut dyn DrawScope) {
38    let [(outer, outer_width), (inner, inner_width)] = focus_ring_lines(scope.size());
39    scope.draw_rect_at_stroked(
40        outer,
41        Brush::solid(FOCUS_RING_COLOR),
42        Stroke::new(outer_width),
43    );
44    scope.draw_rect_at_stroked(
45        inner,
46        Brush::solid(FOCUS_RING_INNER_COLOR),
47        Stroke::new(inner_width),
48    );
49}
50
51impl Modifier {
52    /// A focus target that draws a ring while the keyboard put focus on it.
53    ///
54    /// The ring shows after Tab or an arrow key moved focus here and goes
55    /// away on the next pointer press anywhere, the way a web browser's
56    /// `:focus-visible` works. A control that draws its own focus look uses
57    /// [`focus_target`](Self::focus_target) instead. Compose's
58    /// `Modifier.focusable()`.
59    pub fn focusable(self) -> Self {
60        let focused = Rc::new(Cell::new(false));
61        let seen = Rc::clone(&focused);
62        self.on_focus_changed(move |state| {
63            seen.set(state.is_focused());
64            crate::request_render_invalidation();
65        })
66        .draw_with_content(move |scope| {
67            scope.draw_content();
68            if focused.get() && keyboard_focus_visible() {
69                draw_focus_ring(scope);
70            }
71        })
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn the_ring_stays_inside_the_control() {
81        let [(outer, outer_width), (inner, inner_width)] = focus_ring_lines(Size {
82            width: 48.0,
83            height: 20.0,
84        });
85        assert_eq!((outer_width, inner_width), (2.0, 1.0));
86        assert_eq!(
87            (outer.x, outer.y, outer.width, outer.height),
88            (1.0, 1.0, 46.0, 18.0)
89        );
90        assert_eq!(
91            (inner.x, inner.y, inner.width, inner.height),
92            (2.5, 2.5, 43.0, 15.0)
93        );
94    }
95
96    #[test]
97    fn a_ring_around_nothing_has_no_width() {
98        let [(outer, _), (inner, _)] = focus_ring_lines(Size {
99            width: 1.0,
100            height: 1.0,
101        });
102        assert_eq!(
103            (outer.width, outer.height, inner.width, inner.height),
104            (0.0, 0.0, 0.0, 0.0)
105        );
106    }
107}