diff --git a/README.md b/README.md index 3d59a58..ae1ca95 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,23 @@ them for a lone tiled window: smart_gaps = true ``` +Managed-window borders use theme tokens and overlay the window edge without +adding inner padding or changing client geometry: + +```toml +[theme.colors] +border = "#FFFFFF18" +accent = "#5B8CFF" + +[theme.geometry] +border_width = 1.0 +focus_ring_width = 2.0 +window_radius = 14.0 +``` + +Fullscreen windows are borderless. `window_radius` is validated now and is used +by the rounded-clipping pass described below in the M6 rendering work. + Scrolling keeps windows in stable workspace coordinates and animates the viewport only when focus must be revealed. Set `focus_strategy = "center_on_focus"` under `[scrolling]` to center focused diff --git a/compositor/src/config.rs b/compositor/src/config.rs index d75148b..e9d0d2c 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -23,6 +23,8 @@ pub struct Config { #[serde(default)] input: InputConfig, #[serde(default)] + theme: ThemeConfig, + #[serde(default)] commands: HashMap>, #[serde(default)] bindings: Vec, @@ -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 { + 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 Result { + 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 { + 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( diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 3274857..6f26393 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -45,6 +45,7 @@ fn main() -> Result<(), Box> { 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()?, diff --git a/compositor/src/state.rs b/compositor/src/state.rs index c096067..4d3da64 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -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, pub window_ids: HashMap, pub window_geometry: HashMap, + pub(crate) window_borders: HashMap, viewport_animations: HashMap, scrolling_world_x: HashMap, pub focused_window: Option, @@ -101,6 +102,7 @@ pub struct Ferese { pub(crate) bindings: Vec, window_rules: Vec, window_rules_applied: HashSet, + 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, pub window_rules: Vec, + pub theme_settings: ThemeSettings, pub default_column_width: ColumnWidth, pub scrolling_focus_strategy: ViewportFocusStrategy, pub column_width_presets: Vec, @@ -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) { diff --git a/compositor/src/winit.rs b/compositor/src/winit.rs index 8d1ad9b..b872f1b 100644 --- a/compositor/src/winit.rs +++ b/compositor/src/winit.rs @@ -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, state: &mut Ferese) -> Result<(), Box> { @@ -216,7 +231,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() } pub(crate) fn animated_window_elements( - state: &Ferese, + state: &mut Ferese, renderer: &mut GlesRenderer, output: &Output, ) -> Vec { @@ -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 { @@ -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::::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::>(); + + for (window, id, visual, fullscreen) in windows { + let constrain = Rectangle::::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, + scale: f64, +) -> Vec { + 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, + requested_width: f64, +) -> Vec> { + 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( 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::::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::::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)) + ); + } }