ailloli_ui_core/style/border.rs
1//! Layout-aware per-edge borders and inner-box geometry.
2
3use crate::geometry::{EdgeInsets, Rect};
4
5use super::{Color, Radius};
6
7/// Per-edge border colors.
8///
9/// # Examples
10///
11/// ```
12/// use ailloli_ui_core::style::{Color, EdgeColors};
13/// assert_eq!(EdgeColors::all(Color::WHITE).left, Color::WHITE);
14/// ```
15#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct EdgeColors {
17 /// Left edge color.
18 pub left: Color,
19 /// Top edge color.
20 pub top: Color,
21 /// Right edge color.
22 pub right: Color,
23 /// Bottom edge color.
24 pub bottom: Color,
25}
26
27impl EdgeColors {
28 /// Per-side colors: left, top, right, bottom.
29 ///
30 /// # Examples
31 ///
32 /// ```
33 /// use ailloli_ui_core::style::{Color, EdgeColors};
34 /// assert_eq!(EdgeColors::new(Color::BLACK, Color::WHITE, Color::BLACK, Color::WHITE).top, Color::WHITE);
35 /// ```
36 pub const fn new(left: Color, top: Color, right: Color, bottom: Color) -> Self {
37 Self {
38 left,
39 top,
40 right,
41 bottom,
42 }
43 }
44
45 /// Same color on all sides.
46 ///
47 /// # Examples
48 ///
49 /// ```
50 /// use ailloli_ui_core::style::{Color, EdgeColors};
51 /// assert_eq!(EdgeColors::all(Color::WHITE).bottom, Color::WHITE);
52 /// ```
53 pub const fn all(color: Color) -> Self {
54 Self::new(color, color, color, color)
55 }
56}
57
58impl Default for EdgeColors {
59 fn default() -> Self {
60 Self::all(Color::TRANSPARENT)
61 }
62}
63
64/// Border stroke style.
65///
66/// Possible values are none, solid, dashed, and dotted.
67///
68/// # Examples
69///
70/// ```
71/// use ailloli_ui_core::style::BorderStyle;
72/// assert_eq!(BorderStyle::default(), BorderStyle::None);
73/// ```
74#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
75pub enum BorderStyle {
76 /// No painted border and no border contribution to layout; this is default.
77 #[default]
78 None,
79 /// Continuous stroke.
80 Solid,
81 /// Dashed stroke interpreted by the active renderer.
82 Dashed,
83 /// Dotted stroke interpreted by the active renderer.
84 Dotted,
85}
86
87/// Layout-aware box border.
88///
89/// # Examples
90///
91/// ```
92/// use ailloli_ui_core::style::{Border, Color};
93/// assert!(Border::new(1.0, Color::WHITE).is_visible());
94/// ```
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub struct Border {
97 /// Requested per-edge thicknesses in logical pixels.
98 pub widths: EdgeInsets,
99 /// Per-edge linear-RGBA colors.
100 pub colors: EdgeColors,
101 /// Stroke pattern; [`BorderStyle::None`] disables all layout thickness.
102 pub style: BorderStyle,
103}
104
105impl Border {
106 /// Creates a uniform solid border with the given width and color.
107 ///
108 /// Width is stored verbatim; layout and visibility treat negative values as
109 /// zero through [`Self::layout_widths`].
110 ///
111 /// # Examples
112 ///
113 /// ```
114 /// use ailloli_ui_core::style::{Border, BorderStyle, Color};
115 /// assert_eq!(Border::new(2.0, Color::WHITE).style, BorderStyle::Solid);
116 /// ```
117 pub const fn new(width: f32, color: Color) -> Self {
118 Self {
119 widths: EdgeInsets::all(width),
120 colors: EdgeColors::all(color),
121 style: BorderStyle::Solid,
122 }
123 }
124
125 /// Creates a transparent zero-width [`BorderStyle::None`] border.
126 ///
127 /// # Examples
128 ///
129 /// ```
130 /// use ailloli_ui_core::style::Border;
131 /// assert!(!Border::none().is_visible());
132 /// ```
133 pub const fn none() -> Self {
134 Self {
135 widths: EdgeInsets::all(0.0),
136 colors: EdgeColors::all(Color::TRANSPARENT),
137 style: BorderStyle::None,
138 }
139 }
140
141 /// Alias for [`Self::new`].
142 ///
143 /// # Examples
144 ///
145 /// ```
146 /// use ailloli_ui_core::style::{Border, Color};
147 /// assert_eq!(Border::solid(2.0, Color::WHITE), Border::new(2.0, Color::WHITE));
148 /// ```
149 pub const fn solid(width: f32, color: Color) -> Self {
150 Self::new(width, color)
151 }
152
153 /// Replaces all edge widths and enables [`BorderStyle::Solid`] when positive.
154 ///
155 /// Zero or negative widths do not disable an already enabled style.
156 ///
157 /// # Examples
158 ///
159 /// ```
160 /// use ailloli_ui_core::style::Border;
161 /// assert_eq!(Border::none().with_width(2.0).layout_widths().left, 2.0);
162 /// ```
163 pub fn with_width(mut self, width: f32) -> Self {
164 self.widths = EdgeInsets::all(width);
165 if width > 0.0 && self.style == BorderStyle::None {
166 self.style = BorderStyle::Solid;
167 }
168 self
169 }
170
171 /// Replaces all edge colors and may enable a previously disabled border.
172 ///
173 /// Auto-enabling requires positive alpha and a positive sum of the raw
174 /// widths. A transparent color does not disable an existing style.
175 ///
176 /// # Examples
177 ///
178 /// ```
179 /// use ailloli_ui_core::style::{Border, Color};
180 /// assert_eq!(Border::new(1.0, Color::BLACK).with_color(Color::WHITE).uniform_color(), Some(Color::WHITE));
181 /// ```
182 pub fn with_color(mut self, color: Color) -> Self {
183 self.colors = EdgeColors::all(color);
184 if color.a > 0.0
185 && self.style == BorderStyle::None
186 && self.widths.horizontal() + self.widths.vertical() > 0.0
187 {
188 self.style = BorderStyle::Solid;
189 }
190 self
191 }
192
193 /// Replaces the left width/color and enables a disabled style if width is positive.
194 ///
195 /// # Examples
196 ///
197 /// ```
198 /// use ailloli_ui_core::style::{Border, Color};
199 /// assert_eq!(Border::none().with_left(2.0, Color::WHITE).layout_widths().left, 2.0);
200 /// ```
201 pub fn with_left(mut self, width: f32, color: Color) -> Self {
202 self.widths.left = width;
203 self.colors.left = color;
204 if width > 0.0 && self.style == BorderStyle::None {
205 self.style = BorderStyle::Solid;
206 }
207 self
208 }
209
210 /// Replaces the top width/color and enables a disabled style if width is positive.
211 ///
212 /// # Examples
213 ///
214 /// ```
215 /// use ailloli_ui_core::style::{Border, Color};
216 /// assert_eq!(Border::none().with_top(2.0, Color::WHITE).layout_widths().top, 2.0);
217 /// ```
218 pub fn with_top(mut self, width: f32, color: Color) -> Self {
219 self.widths.top = width;
220 self.colors.top = color;
221 if width > 0.0 && self.style == BorderStyle::None {
222 self.style = BorderStyle::Solid;
223 }
224 self
225 }
226
227 /// Replaces the right width/color and enables a disabled style if width is positive.
228 ///
229 /// # Examples
230 ///
231 /// ```
232 /// use ailloli_ui_core::style::{Border, Color};
233 /// assert_eq!(Border::none().with_right(2.0, Color::WHITE).layout_widths().right, 2.0);
234 /// ```
235 pub fn with_right(mut self, width: f32, color: Color) -> Self {
236 self.widths.right = width;
237 self.colors.right = color;
238 if width > 0.0 && self.style == BorderStyle::None {
239 self.style = BorderStyle::Solid;
240 }
241 self
242 }
243
244 /// Replaces the bottom width/color and enables a disabled style if width is positive.
245 ///
246 /// # Examples
247 ///
248 /// ```
249 /// use ailloli_ui_core::style::{Border, Color};
250 /// assert_eq!(Border::none().with_bottom(2.0, Color::WHITE).layout_widths().bottom, 2.0);
251 /// ```
252 pub fn with_bottom(mut self, width: f32, color: Color) -> Self {
253 self.widths.bottom = width;
254 self.colors.bottom = color;
255 if width > 0.0 && self.style == BorderStyle::None {
256 self.style = BorderStyle::Solid;
257 }
258 self
259 }
260
261 /// Returns non-negative per-edge layout thicknesses in logical pixels.
262 ///
263 /// [`BorderStyle::None`] returns four zeros regardless of stored widths.
264 ///
265 /// # Examples
266 ///
267 /// ```
268 /// use ailloli_ui_core::style::{Border, Color};
269 /// assert_eq!(Border::new(-2.0, Color::WHITE).layout_widths().left, 0.0);
270 /// ```
271 pub fn layout_widths(&self) -> EdgeInsets {
272 if self.style == BorderStyle::None {
273 return EdgeInsets::all(0.0);
274 }
275 EdgeInsets::new(
276 self.widths.left.max(0.0),
277 self.widths.top.max(0.0),
278 self.widths.right.max(0.0),
279 self.widths.bottom.max(0.0),
280 )
281 }
282
283 /// Returns whether at least one edge has positive width and alpha.
284 ///
285 /// A [`BorderStyle::None`] border is never visible.
286 ///
287 /// # Examples
288 ///
289 /// ```
290 /// use ailloli_ui_core::style::{Border, Color};
291 /// assert!(Border::new(1.0, Color::WHITE).is_visible());
292 /// ```
293 pub fn is_visible(&self) -> bool {
294 if self.style == BorderStyle::None {
295 return false;
296 }
297 let w = self.layout_widths();
298 (w.left > 0.0 && self.colors.left.a > 0.0)
299 || (w.top > 0.0 && self.colors.top.a > 0.0)
300 || (w.right > 0.0 && self.colors.right.a > 0.0)
301 || (w.bottom > 0.0 && self.colors.bottom.a > 0.0)
302 }
303
304 /// Returns whether effective widths and all four colors are exactly equal.
305 ///
306 /// This can be `true` for an invisible uniform border.
307 ///
308 /// # Examples
309 ///
310 /// ```
311 /// use ailloli_ui_core::style::Border;
312 /// assert!(Border::none().is_uniform());
313 /// ```
314 pub fn is_uniform(&self) -> bool {
315 self.uniform_width().is_some()
316 && self.colors.left == self.colors.top
317 && self.colors.top == self.colors.right
318 && self.colors.right == self.colors.bottom
319 }
320
321 /// Returns one effective width when all four layout widths are exactly equal.
322 ///
323 /// [`BorderStyle::None`] therefore returns `Some(0.0)`.
324 ///
325 /// # Examples
326 ///
327 /// ```
328 /// use ailloli_ui_core::style::{Border, Color};
329 /// assert_eq!(Border::new(2.0, Color::WHITE).uniform_width(), Some(2.0));
330 /// ```
331 pub fn uniform_width(&self) -> Option<f32> {
332 let w = self.layout_widths();
333 if w.left == w.top && w.top == w.right && w.right == w.bottom {
334 Some(w.left)
335 } else {
336 None
337 }
338 }
339
340 /// Returns one color when all four stored colors are exactly equal.
341 ///
342 /// # Examples
343 ///
344 /// ```
345 /// use ailloli_ui_core::style::{Border, Color};
346 /// assert_eq!(Border::new(2.0, Color::WHITE).uniform_color(), Some(Color::WHITE));
347 /// ```
348 pub fn uniform_color(&self) -> Option<Color> {
349 if self.colors.left == self.colors.top
350 && self.colors.top == self.colors.right
351 && self.colors.right == self.colors.bottom
352 {
353 Some(self.colors.left)
354 } else {
355 None
356 }
357 }
358
359 /// Insets a rectangle by effective border widths.
360 ///
361 /// The returned width and height are floored at zero; its origin still
362 /// advances by the left and top widths when the border exceeds the box.
363 ///
364 /// # Examples
365 ///
366 /// ```
367 /// use ailloli_ui_core::Rect;
368 /// use ailloli_ui_core::style::{Border, Color};
369 /// assert_eq!(Border::new(2.0, Color::WHITE).deflate_rect(Rect::new(0.0, 0.0, 10.0, 10.0)), Rect::new(2.0, 2.0, 6.0, 6.0));
370 /// ```
371 pub fn deflate_rect(&self, rect: Rect) -> Rect {
372 let w = self.layout_widths();
373 Rect::new(
374 rect.x + w.left,
375 rect.y + w.top,
376 (rect.w - w.left - w.right).max(0.0),
377 (rect.h - w.top - w.bottom).max(0.0),
378 )
379 }
380
381 /// Derives non-negative radii for the border's inner contour.
382 ///
383 /// Each outer corner subtracts the greater adjacent effective edge width.
384 ///
385 /// # Examples
386 ///
387 /// ```
388 /// use ailloli_ui_core::style::{Border, Color, Radius};
389 /// assert_eq!(Border::new(2.0, Color::WHITE).inner_radius(Radius::uniform(5.0)), Radius::uniform(3.0));
390 /// ```
391 pub fn inner_radius(&self, radius: Radius) -> Radius {
392 let w = self.layout_widths();
393 Radius::per_corner(
394 (radius.tl - w.left.max(w.top)).max(0.0),
395 (radius.tr - w.right.max(w.top)).max(0.0),
396 (radius.br - w.right.max(w.bottom)).max(0.0),
397 (radius.bl - w.left.max(w.bottom)).max(0.0),
398 )
399 }
400}
401
402impl Default for Border {
403 fn default() -> Self {
404 Self::none()
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 //! Covers uniform construction, effective visibility, and inner geometry.
411
412 use super::*;
413
414 #[test]
415 fn new_creates_uniform_solid_border() {
416 let border = Border::new(2.0, Color::WHITE);
417
418 assert_eq!(border.style, BorderStyle::Solid);
419 assert_eq!(border.widths, EdgeInsets::all(2.0));
420 assert_eq!(border.colors, EdgeColors::all(Color::WHITE));
421 assert!(border.is_visible());
422 assert!(border.is_uniform());
423 assert_eq!(border.uniform_width(), Some(2.0));
424 }
425
426 #[test]
427 fn none_and_zero_width_are_not_visible() {
428 assert!(!Border::none().is_visible());
429 assert!(!Border::new(0.0, Color::WHITE).is_visible());
430 assert!(!Border::new(1.0, Color::TRANSPARENT).is_visible());
431 }
432
433 #[test]
434 fn deflate_rect_and_inner_radius_clamp() {
435 let border = Border {
436 widths: EdgeInsets::new(2.0, 4.0, 6.0, 8.0),
437 colors: EdgeColors::all(Color::WHITE),
438 style: BorderStyle::Solid,
439 };
440
441 assert_eq!(
442 border.deflate_rect(Rect::new(10.0, 20.0, 100.0, 80.0)),
443 Rect::new(12.0, 24.0, 92.0, 68.0)
444 );
445 assert_eq!(
446 border.inner_radius(Radius::per_corner(12.0, 3.0, 9.0, 20.0)),
447 Radius::per_corner(8.0, 0.0, 1.0, 12.0)
448 );
449 }
450}