Skip to main content

sequoia_view/
focus_ring.rs

1use sequoia_core::{Color, Edges, Length, Rect};
2use sequoia_layout::LayoutStyle;
3use sequoia_style::FocusRingStyle;
4
5use crate::any::AnyView;
6use crate::view::{PaintCtx, View};
7use crate::views::Unit;
8
9pub const RING_WIDTH: f32 = 2.0;
10pub const RING_OFFSET: f32 = 2.0;
11
12pub fn ring_rect(rect: Rect, offset: f32) -> Rect {
13    let o = if offset.is_finite() {
14        offset.max(0.0)
15    } else {
16        0.0
17    };
18    rect.inflate(o, o)
19}
20
21pub(crate) fn ring_margin(offset: f32, width: f32) -> f32 {
22    let width = if width.is_finite() {
23        width.max(0.0)
24    } else {
25        0.0
26    };
27    let offset = if offset.is_finite() {
28        offset.max(0.0)
29    } else {
30        0.0
31    };
32    offset + width * 0.5
33}
34
35fn child_box(rect: Rect, margin: f32) -> Rect {
36    let m = if margin.is_finite() {
37        margin.max(0.0)
38    } else {
39        0.0
40    };
41    Rect::from_xywh(
42        rect.left() + m,
43        rect.top() + m,
44        (rect.size.width - m * 2.0).max(0.0),
45        (rect.size.height - m * 2.0).max(0.0),
46    )
47}
48
49#[derive(Clone)]
50pub struct FocusRing {
51    child: AnyView,
52    style: Option<FocusRingStyle>,
53    color: Option<Color>,
54    width: Option<f32>,
55    offset: Option<f32>,
56    radius: Option<f32>,
57    enabled: bool,
58
59    reserve_space: bool,
60}
61
62impl FocusRing {
63    pub fn wrap<V: View + 'static>(child: V) -> Self {
64        Self {
65            child: AnyView::new(child),
66            style: None,
67            color: None,
68            width: None,
69            offset: None,
70            radius: None,
71            enabled: true,
72            reserve_space: false,
73        }
74    }
75
76    pub fn style(mut self, style: FocusRingStyle) -> Self {
77        self.style = Some(style);
78        self
79    }
80
81    pub fn color(mut self, color: Color) -> Self {
82        self.color = Some(color);
83        self
84    }
85
86    pub fn width(mut self, px: f32) -> Self {
87        self.width = Some(px);
88        self
89    }
90
91    pub fn offset(mut self, px: f32) -> Self {
92        self.offset = Some(px);
93        self
94    }
95
96    pub fn radius(mut self, radius: f32) -> Self {
97        self.radius = Some(radius);
98        self
99    }
100
101    pub fn enabled(mut self, on: bool) -> Self {
102        self.enabled = on;
103        self
104    }
105
106    pub fn reserve_space(mut self, on: bool) -> Self {
107        self.reserve_space = on;
108        self
109    }
110
111    fn resolved(&self, theme: &sequoia_style::Theme) -> (Color, f32, f32, f32) {
112        let style = self.style.unwrap_or_default();
113        let color = self.color.or(style.color).unwrap_or(theme.accent);
114        let width = self.width.or(style.width).unwrap_or(RING_WIDTH).max(0.0);
115        let offset = self.offset.or(style.offset).unwrap_or(RING_OFFSET).max(0.0);
116        let radius = self
117            .radius
118            .or(style.radius)
119            .unwrap_or(theme.radius)
120            .max(0.0);
121        (color, width, offset, radius)
122    }
123
124    pub fn reserved_margin(&self) -> f32 {
125        if !self.enabled || !self.reserve_space {
126            return 0.0;
127        }
128        let style = self.style.unwrap_or_default();
129        let width = self.width.or(style.width).unwrap_or(RING_WIDTH).max(0.0);
130        let offset = self.offset.or(style.offset).unwrap_or(RING_OFFSET).max(0.0);
131        ring_margin(offset, width)
132    }
133
134    pub fn should_paint(&self, focus_within: bool) -> bool {
135        self.enabled && focus_within
136    }
137}
138
139impl View for FocusRing {
140    fn body(&self) -> impl View + 'static {
141        Unit
142    }
143    fn children_override(&self) -> Option<Vec<AnyView>> {
144        Some(vec![self.child.clone()])
145    }
146    fn layout_style(&self) -> LayoutStyle {
147        let m = self.reserved_margin();
148        LayoutStyle {
149            width: Length::Fit,
150            height: Length::Fit,
151
152            padding: if m > 0.0 {
153                Edges::uniform(m)
154            } else {
155                Edges::ZERO
156            },
157            ..LayoutStyle::default()
158        }
159    }
160
161    fn hit_testable(&self) -> bool {
162        false
163    }
164
165    fn paint_self(&self, ctx: &mut PaintCtx) {
166        if !self.should_paint(ctx.has_focus_within()) {
167            return;
168        }
169        let theme = ctx.theme();
170        let (color, width, offset, radius) = self.resolved(&theme);
171        if width <= 0.0 || color.a <= 0.0 {
172            return;
173        }
174
175        let rect = ring_rect(child_box(ctx.rect, self.reserved_margin()), offset);
176        if rect.size.width <= 0.0 || rect.size.height <= 0.0 {
177            return;
178        }
179        ctx.bordered_quad(rect, Color::TRANSPARENT, radius + offset, width, color);
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn ring_rect_inflates_by_offset() {
189        let r = Rect::from_xywh(10.0, 20.0, 100.0, 40.0);
190        assert_eq!(ring_rect(r, 2.0), Rect::from_xywh(8.0, 18.0, 104.0, 44.0));
191        assert_eq!(ring_rect(r, 0.0), r);
192        assert_eq!(ring_rect(r, -5.0), r, "ujemny offset = brak");
193    }
194
195    #[test]
196    fn should_paint_needs_enabled_and_focus() {
197        let ring = FocusRing::wrap(Unit);
198        assert!(ring.should_paint(true));
199        assert!(!ring.should_paint(false));
200        assert!(!ring.enabled(false).should_paint(true));
201    }
202
203    #[test]
204    fn builder_overrides_style_over_theme() {
205        let theme = sequoia_style::Theme::dark();
206        let ring = FocusRing::wrap(Unit)
207            .style(FocusRingStyle::new().width(3.0))
208            .width(5.0);
209        let (_, w, _, _) = ring.resolved(&theme);
210        assert_eq!(w, 5.0, "builder wygrywa ze stylem");
211        let ring = FocusRing::wrap(Unit).style(FocusRingStyle::new().width(3.0));
212        let (_, w, _, _) = ring.resolved(&theme);
213        assert_eq!(w, 3.0, "styl wygrywa z theme");
214        let ring = FocusRing::wrap(Unit);
215        let (c, w, o, _) = ring.resolved(&theme);
216        assert_eq!(c, theme.accent);
217        assert_eq!(w, RING_WIDTH);
218        assert_eq!(o, RING_OFFSET);
219    }
220
221    #[test]
222    fn ring_margin_is_offset_plus_half_width() {
223        assert!((ring_margin(RING_OFFSET, RING_WIDTH) - 3.0).abs() < 1e-6);
224        assert!((ring_margin(1.0, 2.0) - 2.0).abs() < 1e-6);
225
226        assert_eq!(ring_margin(f32::NAN, f32::NAN), 0.0);
227        assert_eq!(ring_margin(-5.0, -1.0), 0.0);
228    }
229
230    #[test]
231    fn child_box_insets_by_margin() {
232        let r = Rect::from_xywh(0.0, 0.0, 14.0, 14.0);
233        assert_eq!(child_box(r, 3.0), Rect::from_xywh(3.0, 3.0, 8.0, 8.0));
234        assert_eq!(child_box(r, 0.0), r);
235
236        assert_eq!(child_box(r, 100.0).size.width, 0.0);
237    }
238
239    #[test]
240    fn layout_reserves_only_when_opted_in() {
241        use sequoia_core::Edges;
242
243        let ring = FocusRing::wrap(Unit);
244        assert_eq!(ring.reserved_margin(), 0.0);
245        assert_eq!(ring.layout_style().padding, Edges::ZERO);
246        assert_eq!(ring.layout_style().width, Length::Fit);
247
248        let ring = FocusRing::wrap(Unit).reserve_space(true);
249        assert_eq!(ring.reserved_margin(), 3.0);
250        assert_eq!(ring.layout_style().padding, Edges::uniform(3.0));
251
252        let ring = FocusRing::wrap(Unit)
253            .reserve_space(true)
254            .offset(1.0)
255            .width(1.0);
256        assert_eq!(ring.reserved_margin(), 1.5);
257
258        assert_eq!(FocusRing::wrap(Unit).enabled(false).reserved_margin(), 0.0);
259        assert_eq!(
260            FocusRing::wrap(Unit)
261                .reserve_space(true)
262                .enabled(false)
263                .reserved_margin(),
264            0.0
265        );
266    }
267
268    #[test]
269    fn margin_is_click_through() {
270        assert!(!FocusRing::wrap(Unit).hit_testable());
271        assert!(!FocusRing::wrap(Unit).enabled(false).hit_testable());
272    }
273
274    #[test]
275    fn optin_ink_fits_inside_reserved_box() {
276        let ring = FocusRing::wrap(Unit).reserve_space(true);
277        let wrapper = Rect::from_xywh(0.0, 0.0, 14.0, 14.0);
278        let child = child_box(wrapper, ring.reserved_margin());
279        let path = ring_rect(child, 2.0);
280        let ink_outer = path.left() - 2.0 * 0.5;
281        assert!(
282            ink_outer >= wrapper.left() - 1e-6,
283            "atrament nie wystaje poza box wrappera: {ink_outer}"
284        );
285    }
286
287    #[test]
288    fn default_ring_flush_with_child() {
289        let ring = FocusRing::wrap(Unit);
290        let wrapper = Rect::from_xywh(0.0, 0.0, 8.0, 8.0);
291        assert_eq!(child_box(wrapper, ring.reserved_margin()), wrapper);
292    }
293}