feat: render theme-aware window borders
This commit is contained in:
1 parent
d6acfa89e7
commit
5bdb7bdf24
5 files changed
+384
-46
No files matched your search
+177
-1
@@ -23,6 +23,8 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
input: InputConfig,
|
||||
#[serde(default)]
|
||||
theme: ThemeConfig,
|
||||
#[serde(default)]
|
||||
commands: HashMap<String, Vec<String>>,
|
||||
#[serde(default)]
|
||||
bindings: Vec<BindingConfig>,
|
||||
@@ -32,6 +34,63 @@ pub struct Config {
|
||||
scrolling: ScrollingConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RgbaColor(pub [f32; 4]);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ThemeSettings {
|
||||
pub border_width: f64,
|
||||
pub focus_ring_width: f64,
|
||||
pub border_color: RgbaColor,
|
||||
pub accent_color: RgbaColor,
|
||||
pub window_radius: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ThemeConfig {
|
||||
#[serde(default)]
|
||||
colors: ThemeColorsConfig,
|
||||
#[serde(default)]
|
||||
geometry: ThemeGeometryConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThemeColorsConfig {
|
||||
#[serde(default = "default_border_color")]
|
||||
border: String,
|
||||
#[serde(default = "default_accent_color")]
|
||||
accent: String,
|
||||
}
|
||||
|
||||
impl Default for ThemeColorsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
border: default_border_color(),
|
||||
accent: default_accent_color(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ThemeGeometryConfig {
|
||||
#[serde(default = "default_border_width")]
|
||||
border_width: f64,
|
||||
#[serde(default = "default_focus_ring_width")]
|
||||
focus_ring_width: f64,
|
||||
#[serde(default = "default_window_radius")]
|
||||
window_radius: f64,
|
||||
}
|
||||
|
||||
impl Default for ThemeGeometryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
border_width: default_border_width(),
|
||||
focus_ring_width: default_focus_ring_width(),
|
||||
window_radius: default_window_radius(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Binding {
|
||||
modifiers: BindingModifiers,
|
||||
@@ -329,6 +388,10 @@ pub enum ConfigError {
|
||||
},
|
||||
InvalidBinding(String),
|
||||
InvalidWindowRule(String),
|
||||
InvalidThemeValue {
|
||||
field: &'static str,
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigError {
|
||||
@@ -355,6 +418,9 @@ impl fmt::Display for ConfigError {
|
||||
}
|
||||
Self::InvalidBinding(message) => write!(formatter, "invalid binding: {message}"),
|
||||
Self::InvalidWindowRule(message) => write!(formatter, "invalid window rule: {message}"),
|
||||
Self::InvalidThemeValue { field, value } => {
|
||||
write!(formatter, "invalid theme.{field} value {value}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,7 +435,8 @@ impl Error for ConfigError {
|
||||
| Self::InvalidLayoutValue { .. }
|
||||
| Self::InvalidInputValue { .. }
|
||||
| Self::InvalidBinding(_)
|
||||
| Self::InvalidWindowRule(_) => None,
|
||||
| Self::InvalidWindowRule(_)
|
||||
| Self::InvalidThemeValue { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -520,6 +587,25 @@ impl Config {
|
||||
window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule)
|
||||
}
|
||||
|
||||
pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> {
|
||||
let border_width =
|
||||
nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?;
|
||||
let focus_ring_width = nonnegative_theme_value(
|
||||
self.theme.geometry.focus_ring_width,
|
||||
"geometry.focus_ring_width",
|
||||
)?;
|
||||
let window_radius =
|
||||
nonnegative_theme_value(self.theme.geometry.window_radius, "geometry.window_radius")?;
|
||||
|
||||
Ok(ThemeSettings {
|
||||
border_width,
|
||||
focus_ring_width,
|
||||
border_color: parse_color(&self.theme.colors.border, "colors.border")?,
|
||||
accent_color: parse_color(&self.theme.colors.accent, "colors.accent")?,
|
||||
window_radius,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn animations_enabled(&self) -> bool {
|
||||
self.animations.enabled && !self.animations.reduced_motion
|
||||
}
|
||||
@@ -908,6 +994,54 @@ fn nonnegative_layout_value(value: f64, field: &'static str) -> Result<f64, Conf
|
||||
}
|
||||
}
|
||||
|
||||
fn nonnegative_theme_value(value: f64, field: &'static str) -> Result<f64, ConfigError> {
|
||||
if value.is_finite() && value >= 0.0 {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(ConfigError::InvalidThemeValue {
|
||||
field,
|
||||
value: value.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_color(value: &str, field: &'static str) -> Result<RgbaColor, ConfigError> {
|
||||
let digits = value.strip_prefix('#').unwrap_or(value);
|
||||
if !digits.is_ascii() || !matches!(digits.len(), 6 | 8) {
|
||||
return Err(ConfigError::InvalidThemeValue {
|
||||
field,
|
||||
value: value.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let parse_channel = |offset| u8::from_str_radix(&digits[offset..offset + 2], 16).ok();
|
||||
let Some((red, green, blue)) = parse_channel(0)
|
||||
.zip(parse_channel(2))
|
||||
.zip(parse_channel(4))
|
||||
.map(|((red, green), blue)| (red, green, blue))
|
||||
else {
|
||||
return Err(ConfigError::InvalidThemeValue {
|
||||
field,
|
||||
value: value.to_owned(),
|
||||
});
|
||||
};
|
||||
let alpha = if digits.len() == 8 {
|
||||
parse_channel(6).ok_or_else(|| ConfigError::InvalidThemeValue {
|
||||
field,
|
||||
value: value.to_owned(),
|
||||
})?
|
||||
} else {
|
||||
u8::MAX
|
||||
};
|
||||
|
||||
Ok(RgbaColor([
|
||||
f32::from(red) / 255.0,
|
||||
f32::from(green) / 255.0,
|
||||
f32::from(blue) / 255.0,
|
||||
f32::from(alpha) / 255.0,
|
||||
]))
|
||||
}
|
||||
|
||||
const fn enabled_by_default() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -920,6 +1054,26 @@ const fn default_outer_gap() -> f64 {
|
||||
10.0
|
||||
}
|
||||
|
||||
fn default_border_color() -> String {
|
||||
"#FFFFFF18".to_owned()
|
||||
}
|
||||
|
||||
fn default_accent_color() -> String {
|
||||
"#5B8CFF".to_owned()
|
||||
}
|
||||
|
||||
const fn default_border_width() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
const fn default_focus_ring_width() -> f64 {
|
||||
2.0
|
||||
}
|
||||
|
||||
const fn default_window_radius() -> f64 {
|
||||
14.0
|
||||
}
|
||||
|
||||
fn default_xkb_layout() -> String {
|
||||
"us".to_owned()
|
||||
}
|
||||
@@ -1108,6 +1262,28 @@ mod tests {
|
||||
assert!(invalid.gap_config().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_validates_theme_window_tokens() {
|
||||
let configured = parse(
|
||||
"[theme.colors]\nborder = \"#11223344\"\naccent = \"#AABBCC\"\n\n[theme.geometry]\nborder_width = 1.5\nfocus_ring_width = 3.0\nwindow_radius = 12.0",
|
||||
);
|
||||
let invalid_color = parse("[theme.colors]\naccent = \"blue\"");
|
||||
let invalid_radius = parse("[theme.geometry]\nwindow_radius = -1.0");
|
||||
|
||||
assert_eq!(
|
||||
configured.theme_settings().unwrap(),
|
||||
ThemeSettings {
|
||||
border_width: 1.5,
|
||||
focus_ring_width: 3.0,
|
||||
border_color: RgbaColor([17.0 / 255.0, 34.0 / 255.0, 51.0 / 255.0, 68.0 / 255.0,]),
|
||||
accent_color: RgbaColor([170.0 / 255.0, 187.0 / 255.0, 0.8, 1.0]),
|
||||
window_radius: 12.0,
|
||||
}
|
||||
);
|
||||
assert!(invalid_color.theme_settings().is_err());
|
||||
assert!(invalid_radius.theme_settings().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_input_and_touchpad_settings() {
|
||||
let config = parse(
|
||||
|
||||
@@ -45,6 +45,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
input_settings,
|
||||
bindings,
|
||||
window_rules,
|
||||
theme_settings: config.theme_settings()?,
|
||||
default_column_width: config.default_column_width()?,
|
||||
scrolling_focus_strategy: config.scrolling_focus_strategy(),
|
||||
column_width_presets: config.width_presets()?,
|
||||
|
||||
@@ -76,7 +76,7 @@ use smithay::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{Binding, InputSettings},
|
||||
config::{Binding, InputSettings, ThemeSettings},
|
||||
window_rules::{WindowRule, resolve as resolve_window_rules},
|
||||
};
|
||||
|
||||
@@ -92,6 +92,7 @@ pub struct Ferese {
|
||||
output_identity_ids: HashMap<String, OutputId>,
|
||||
pub window_ids: HashMap<Window, WindowId>,
|
||||
pub window_geometry: HashMap<WindowId, WindowGeometry>,
|
||||
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
|
||||
viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
|
||||
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
|
||||
pub focused_window: Option<WindowId>,
|
||||
@@ -101,6 +102,7 @@ pub struct Ferese {
|
||||
pub(crate) bindings: Vec<Binding>,
|
||||
window_rules: Vec<WindowRule>,
|
||||
window_rules_applied: HashSet<WindowId>,
|
||||
pub(crate) theme_settings: ThemeSettings,
|
||||
animations_enabled: bool,
|
||||
animation_speed: f64,
|
||||
spring_config: SpringConfig,
|
||||
@@ -153,6 +155,7 @@ pub struct RuntimeConfig {
|
||||
pub input_settings: InputSettings,
|
||||
pub bindings: Vec<Binding>,
|
||||
pub window_rules: Vec<WindowRule>,
|
||||
pub theme_settings: ThemeSettings,
|
||||
pub default_column_width: ColumnWidth,
|
||||
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
||||
pub column_width_presets: Vec<ColumnWidth>,
|
||||
@@ -390,6 +393,7 @@ impl Ferese {
|
||||
output_identity_ids: HashMap::new(),
|
||||
window_ids: HashMap::new(),
|
||||
window_geometry: HashMap::new(),
|
||||
window_borders: HashMap::new(),
|
||||
viewport_animations: HashMap::new(),
|
||||
scrolling_world_x: HashMap::new(),
|
||||
focused_window: None,
|
||||
@@ -399,6 +403,7 @@ impl Ferese {
|
||||
bindings: config.bindings,
|
||||
window_rules: config.window_rules,
|
||||
window_rules_applied: HashSet::new(),
|
||||
theme_settings: config.theme_settings,
|
||||
animations_enabled: config.animations_enabled,
|
||||
animation_speed: config.animation_speed,
|
||||
spring_config: config.spring_config,
|
||||
@@ -772,6 +777,7 @@ impl Ferese {
|
||||
|
||||
self.space.unmap_elem(window);
|
||||
self.window_geometry.remove(&id);
|
||||
self.window_borders.remove(&id);
|
||||
self.window_rules_applied.remove(&id);
|
||||
self.scrolling_world_x.remove(&id);
|
||||
if let Err(error) = self.workspaces.remove_window(id) {
|
||||
|
||||
+182
-44
@@ -12,6 +12,7 @@ use smithay::{
|
||||
AsRenderElements, Kind as RenderElementKind, RenderElement,
|
||||
memory::MemoryRenderBufferRenderElement,
|
||||
render_elements,
|
||||
solid::{SolidColorBuffer, SolidColorRenderElement},
|
||||
surface::{WaylandSurfaceRenderElement, render_elements_from_surface_tree},
|
||||
utils::{
|
||||
ConstrainAlign, ConstrainScaleBehavior, CropRenderElement, Relocate,
|
||||
@@ -51,6 +52,20 @@ render_elements! {
|
||||
pub(crate) AnimatedWindowRenderElement<=GlesRenderer>;
|
||||
Surface=SurfaceRenderElement,
|
||||
Memory=MemoryRenderElement,
|
||||
Solid=SolidColorRenderElement,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WindowBorderBuffers {
|
||||
edges: [SolidColorBuffer; 4],
|
||||
}
|
||||
|
||||
impl Default for WindowBorderBuffers {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
edges: std::array::from_fn(|_| SolidColorBuffer::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<(), Box<dyn Error>> {
|
||||
@@ -216,7 +231,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
|
||||
}
|
||||
|
||||
pub(crate) fn animated_window_elements(
|
||||
state: &Ferese,
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
output: &Output,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
@@ -224,7 +239,7 @@ pub(crate) fn animated_window_elements(
|
||||
}
|
||||
|
||||
pub(crate) fn cursorless_window_elements(
|
||||
state: &Ferese,
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
output: &Output,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
@@ -232,7 +247,7 @@ pub(crate) fn cursorless_window_elements(
|
||||
}
|
||||
|
||||
fn output_elements(
|
||||
state: &Ferese,
|
||||
state: &mut Ferese,
|
||||
renderer: &mut GlesRenderer,
|
||||
output: &Output,
|
||||
include_cursor: bool,
|
||||
@@ -252,47 +267,58 @@ fn output_elements(
|
||||
output,
|
||||
&[Layer::Overlay, Layer::Top],
|
||||
));
|
||||
elements.extend(
|
||||
state
|
||||
.space
|
||||
.elements()
|
||||
.rev()
|
||||
.filter_map(|window| {
|
||||
let id = state.window_ids.get(window)?;
|
||||
let visual = state.window_geometry.get(id)?.visual.current;
|
||||
let constrain = Rectangle::<i32, Logical>::new(
|
||||
(
|
||||
(visual.x - f64::from(output_geometry.loc.x)).round() as i32,
|
||||
(visual.y - f64::from(output_geometry.loc.y)).round() as i32,
|
||||
)
|
||||
.into(),
|
||||
(
|
||||
visual.width.round().max(1.0) as i32,
|
||||
visual.height.round().max(1.0) as i32,
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
let windows = state
|
||||
.space
|
||||
.elements()
|
||||
.rev()
|
||||
.filter_map(|window| {
|
||||
let id = *state.window_ids.get(window)?;
|
||||
let visual = state.window_geometry.get(&id)?.visual.current;
|
||||
let fullscreen = state
|
||||
.workspaces
|
||||
.workspace_for_window(id)
|
||||
.and_then(|workspace| state.workspaces.workspace(workspace))
|
||||
.is_some_and(|workspace| workspace.fullscreen == Some(id));
|
||||
|
||||
Some(constrain_space_element::<
|
||||
GlesRenderer,
|
||||
_,
|
||||
AnimatedWindowRenderElement,
|
||||
>(
|
||||
renderer,
|
||||
window,
|
||||
constrain.loc,
|
||||
1.0,
|
||||
scale,
|
||||
constrain,
|
||||
ConstrainBehavior {
|
||||
reference: ConstrainReference::Geometry,
|
||||
behavior: ConstrainScaleBehavior::Stretch,
|
||||
align: ConstrainAlign::TOP | ConstrainAlign::LEFT,
|
||||
},
|
||||
))
|
||||
})
|
||||
.flatten(),
|
||||
);
|
||||
Some((window.clone(), id, visual, fullscreen))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (window, id, visual, fullscreen) in windows {
|
||||
let constrain = Rectangle::<i32, Logical>::new(
|
||||
(
|
||||
(visual.x - f64::from(output_geometry.loc.x)).round() as i32,
|
||||
(visual.y - f64::from(output_geometry.loc.y)).round() as i32,
|
||||
)
|
||||
.into(),
|
||||
(
|
||||
visual.width.round().max(1.0) as i32,
|
||||
visual.height.round().max(1.0) as i32,
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
|
||||
if !fullscreen {
|
||||
elements.extend(window_border_elements(state, id, constrain, scale));
|
||||
}
|
||||
elements.extend(constrain_space_element::<
|
||||
GlesRenderer,
|
||||
_,
|
||||
AnimatedWindowRenderElement,
|
||||
>(
|
||||
renderer,
|
||||
&window,
|
||||
constrain.loc,
|
||||
1.0,
|
||||
scale,
|
||||
constrain,
|
||||
ConstrainBehavior {
|
||||
reference: ConstrainReference::Geometry,
|
||||
behavior: ConstrainScaleBehavior::Stretch,
|
||||
align: ConstrainAlign::TOP | ConstrainAlign::LEFT,
|
||||
},
|
||||
));
|
||||
}
|
||||
elements.extend(layer_elements(
|
||||
renderer,
|
||||
output,
|
||||
@@ -301,6 +327,88 @@ fn output_elements(
|
||||
elements
|
||||
}
|
||||
|
||||
fn window_border_elements(
|
||||
state: &mut Ferese,
|
||||
id: ferese_layout::WindowId,
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
scale: f64,
|
||||
) -> Vec<AnimatedWindowRenderElement> {
|
||||
let focused = state.focused_window == Some(id);
|
||||
let width = if focused {
|
||||
state.theme_settings.focus_ring_width
|
||||
} else {
|
||||
state.theme_settings.border_width
|
||||
};
|
||||
let rectangles = border_rectangles(geometry, width);
|
||||
if rectangles.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let color = if focused {
|
||||
state.theme_settings.accent_color.0
|
||||
} else {
|
||||
state.theme_settings.border_color.0
|
||||
};
|
||||
let buffers = state.window_borders.entry(id).or_default();
|
||||
|
||||
buffers
|
||||
.edges
|
||||
.iter_mut()
|
||||
.zip(rectangles)
|
||||
.map(|(buffer, rectangle)| {
|
||||
buffer.update(rectangle.size, color);
|
||||
SolidColorRenderElement::from_buffer(
|
||||
buffer,
|
||||
rectangle.loc.to_physical_precise_round(scale),
|
||||
scale,
|
||||
1.0,
|
||||
RenderElementKind::Unspecified,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn border_rectangles(
|
||||
geometry: Rectangle<i32, Logical>,
|
||||
requested_width: f64,
|
||||
) -> Vec<Rectangle<i32, Logical>> {
|
||||
let width = requested_width
|
||||
.ceil()
|
||||
.max(0.0)
|
||||
.min(f64::from(geometry.size.w.min(geometry.size.h)) / 2.0) as i32;
|
||||
if width == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let inner_height = geometry.size.h - width * 2;
|
||||
let mut rectangles = vec![
|
||||
Rectangle::new(geometry.loc, (geometry.size.w, width).into()),
|
||||
Rectangle::new(
|
||||
(geometry.loc.x, geometry.loc.y + geometry.size.h - width).into(),
|
||||
(geometry.size.w, width).into(),
|
||||
),
|
||||
];
|
||||
if inner_height > 0 {
|
||||
rectangles.extend([
|
||||
Rectangle::new(
|
||||
(geometry.loc.x, geometry.loc.y + width).into(),
|
||||
(width, inner_height).into(),
|
||||
),
|
||||
Rectangle::new(
|
||||
(
|
||||
geometry.loc.x + geometry.size.w - width,
|
||||
geometry.loc.y + width,
|
||||
)
|
||||
.into(),
|
||||
(width, inner_height).into(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
rectangles
|
||||
}
|
||||
|
||||
pub(crate) fn redraw_output<R, E>(
|
||||
renderer: &mut R,
|
||||
framebuffer: &mut R::Framebuffer<'_>,
|
||||
@@ -465,7 +573,9 @@ fn normalized_scale(scale: f64) -> f64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::normalized_scale;
|
||||
use smithay::utils::{Logical, Rectangle};
|
||||
|
||||
use super::{border_rectangles, normalized_scale};
|
||||
|
||||
#[test]
|
||||
fn accepts_positive_finite_scale() {
|
||||
@@ -478,4 +588,32 @@ mod tests {
|
||||
assert_eq!(normalized_scale(f64::NAN), 1.0);
|
||||
assert_eq!(normalized_scale(f64::INFINITY), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_rectangles_stay_inside_window_geometry() {
|
||||
let geometry = Rectangle::<i32, Logical>::new((10, 20).into(), (100, 80).into());
|
||||
|
||||
assert_eq!(
|
||||
border_rectangles(geometry, 2.0),
|
||||
vec![
|
||||
Rectangle::new((10, 20).into(), (100, 2).into()),
|
||||
Rectangle::new((10, 98).into(), (100, 2).into()),
|
||||
Rectangle::new((10, 22).into(), (2, 76).into()),
|
||||
Rectangle::new((108, 22).into(), (2, 76).into()),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn border_width_is_clamped_for_tiny_windows() {
|
||||
let geometry = Rectangle::<i32, Logical>::new((0, 0).into(), (3, 2).into());
|
||||
let rectangles = border_rectangles(geometry, 20.0);
|
||||
|
||||
assert_eq!(rectangles.len(), 2);
|
||||
assert!(
|
||||
rectangles
|
||||
.iter()
|
||||
.all(|rectangle| geometry.contains_rect(*rectangle))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user