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}