feat: render theme-aware window borders

This commit is contained in:
Samuel Onoja committed 2026-09-25 03:17:17 +01:00
1 parent d6acfa89e7
commit 5bdb7bdf24
5 files changed
+354 -16

No files matched your search

+17
View File
@@ -45,6 +45,23 @@ them for a lone tiled window:
smart_gaps = true 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 Scrolling keeps windows in stable workspace coordinates and animates the
viewport only when focus must be revealed. Set viewport only when focus must be revealed. Set
`focus_strategy = "center_on_focus"` under `[scrolling]` to center focused `focus_strategy = "center_on_focus"` under `[scrolling]` to center focused
+177 -1
View File
@@ -23,6 +23,8 @@ pub struct Config {
#[serde(default)] #[serde(default)]
input: InputConfig, input: InputConfig,
#[serde(default)] #[serde(default)]
theme: ThemeConfig,
#[serde(default)]
commands: HashMap<String, Vec<String>>, commands: HashMap<String, Vec<String>>,
#[serde(default)] #[serde(default)]
bindings: Vec<BindingConfig>, bindings: Vec<BindingConfig>,
@@ -32,6 +34,63 @@ pub struct Config {
scrolling: ScrollingConfig, 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)] #[derive(Clone, Debug, PartialEq)]
pub struct Binding { pub struct Binding {
modifiers: BindingModifiers, modifiers: BindingModifiers,
@@ -329,6 +388,10 @@ pub enum ConfigError {
}, },
InvalidBinding(String), InvalidBinding(String),
InvalidWindowRule(String), InvalidWindowRule(String),
InvalidThemeValue {
field: &'static str,
value: String,
},
} }
impl fmt::Display for ConfigError { impl fmt::Display for ConfigError {
@@ -355,6 +418,9 @@ impl fmt::Display for ConfigError {
} }
Self::InvalidBinding(message) => write!(formatter, "invalid binding: {message}"), Self::InvalidBinding(message) => write!(formatter, "invalid binding: {message}"),
Self::InvalidWindowRule(message) => write!(formatter, "invalid window rule: {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::InvalidLayoutValue { .. }
| Self::InvalidInputValue { .. } | Self::InvalidInputValue { .. }
| Self::InvalidBinding(_) | 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) 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 { pub fn animations_enabled(&self) -> bool {
self.animations.enabled && !self.animations.reduced_motion 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 { const fn enabled_by_default() -> bool {
true true
} }
@@ -920,6 +1054,26 @@ const fn default_outer_gap() -> f64 {
10.0 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 { fn default_xkb_layout() -> String {
"us".to_owned() "us".to_owned()
} }
@@ -1108,6 +1262,28 @@ mod tests {
assert!(invalid.gap_config().is_err()); 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] #[test]
fn parses_input_and_touchpad_settings() { fn parses_input_and_touchpad_settings() {
let config = parse( let config = parse(
+1
View File
@@ -45,6 +45,7 @@ fn main() -> Result<(), Box<dyn Error>> {
input_settings, input_settings,
bindings, bindings,
window_rules, window_rules,
theme_settings: config.theme_settings()?,
default_column_width: config.default_column_width()?, default_column_width: config.default_column_width()?,
scrolling_focus_strategy: config.scrolling_focus_strategy(), scrolling_focus_strategy: config.scrolling_focus_strategy(),
column_width_presets: config.width_presets()?, column_width_presets: config.width_presets()?,
+7 -1
View File
@@ -76,7 +76,7 @@ use smithay::{
}; };
use crate::{ use crate::{
config::{Binding, InputSettings}, config::{Binding, InputSettings, ThemeSettings},
window_rules::{WindowRule, resolve as resolve_window_rules}, window_rules::{WindowRule, resolve as resolve_window_rules},
}; };
@@ -92,6 +92,7 @@ pub struct Ferese {
output_identity_ids: HashMap<String, OutputId>, output_identity_ids: HashMap<String, OutputId>,
pub window_ids: HashMap<Window, WindowId>, pub window_ids: HashMap<Window, WindowId>,
pub window_geometry: HashMap<WindowId, WindowGeometry>, pub window_geometry: HashMap<WindowId, WindowGeometry>,
pub(crate) window_borders: HashMap<WindowId, crate::winit::WindowBorderBuffers>,
viewport_animations: HashMap<WorkspaceId, AnimatedValue>, viewport_animations: HashMap<WorkspaceId, AnimatedValue>,
scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>, scrolling_world_x: HashMap<WindowId, (WorkspaceId, AnimatedValue)>,
pub focused_window: Option<WindowId>, pub focused_window: Option<WindowId>,
@@ -101,6 +102,7 @@ pub struct Ferese {
pub(crate) bindings: Vec<Binding>, pub(crate) bindings: Vec<Binding>,
window_rules: Vec<WindowRule>, window_rules: Vec<WindowRule>,
window_rules_applied: HashSet<WindowId>, window_rules_applied: HashSet<WindowId>,
pub(crate) theme_settings: ThemeSettings,
animations_enabled: bool, animations_enabled: bool,
animation_speed: f64, animation_speed: f64,
spring_config: SpringConfig, spring_config: SpringConfig,
@@ -153,6 +155,7 @@ pub struct RuntimeConfig {
pub input_settings: InputSettings, pub input_settings: InputSettings,
pub bindings: Vec<Binding>, pub bindings: Vec<Binding>,
pub window_rules: Vec<WindowRule>, pub window_rules: Vec<WindowRule>,
pub theme_settings: ThemeSettings,
pub default_column_width: ColumnWidth, pub default_column_width: ColumnWidth,
pub scrolling_focus_strategy: ViewportFocusStrategy, pub scrolling_focus_strategy: ViewportFocusStrategy,
pub column_width_presets: Vec<ColumnWidth>, pub column_width_presets: Vec<ColumnWidth>,
@@ -390,6 +393,7 @@ impl Ferese {
output_identity_ids: HashMap::new(), output_identity_ids: HashMap::new(),
window_ids: HashMap::new(), window_ids: HashMap::new(),
window_geometry: HashMap::new(), window_geometry: HashMap::new(),
window_borders: HashMap::new(),
viewport_animations: HashMap::new(), viewport_animations: HashMap::new(),
scrolling_world_x: HashMap::new(), scrolling_world_x: HashMap::new(),
focused_window: None, focused_window: None,
@@ -399,6 +403,7 @@ impl Ferese {
bindings: config.bindings, bindings: config.bindings,
window_rules: config.window_rules, window_rules: config.window_rules,
window_rules_applied: HashSet::new(), window_rules_applied: HashSet::new(),
theme_settings: config.theme_settings,
animations_enabled: config.animations_enabled, animations_enabled: config.animations_enabled,
animation_speed: config.animation_speed, animation_speed: config.animation_speed,
spring_config: config.spring_config, spring_config: config.spring_config,
@@ -772,6 +777,7 @@ impl Ferese {
self.space.unmap_elem(window); self.space.unmap_elem(window);
self.window_geometry.remove(&id); self.window_geometry.remove(&id);
self.window_borders.remove(&id);
self.window_rules_applied.remove(&id); self.window_rules_applied.remove(&id);
self.scrolling_world_x.remove(&id); self.scrolling_world_x.remove(&id);
if let Err(error) = self.workspaces.remove_window(id) { if let Err(error) = self.workspaces.remove_window(id) {
+152 -14
View File
@@ -12,6 +12,7 @@ use smithay::{
AsRenderElements, Kind as RenderElementKind, RenderElement, AsRenderElements, Kind as RenderElementKind, RenderElement,
memory::MemoryRenderBufferRenderElement, memory::MemoryRenderBufferRenderElement,
render_elements, render_elements,
solid::{SolidColorBuffer, SolidColorRenderElement},
surface::{WaylandSurfaceRenderElement, render_elements_from_surface_tree}, surface::{WaylandSurfaceRenderElement, render_elements_from_surface_tree},
utils::{ utils::{
ConstrainAlign, ConstrainScaleBehavior, CropRenderElement, Relocate, ConstrainAlign, ConstrainScaleBehavior, CropRenderElement, Relocate,
@@ -51,6 +52,20 @@ render_elements! {
pub(crate) AnimatedWindowRenderElement<=GlesRenderer>; pub(crate) AnimatedWindowRenderElement<=GlesRenderer>;
Surface=SurfaceRenderElement, Surface=SurfaceRenderElement,
Memory=MemoryRenderElement, 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>> { 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( pub(crate) fn animated_window_elements(
state: &Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
output: &Output, output: &Output,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
@@ -224,7 +239,7 @@ pub(crate) fn animated_window_elements(
} }
pub(crate) fn cursorless_window_elements( pub(crate) fn cursorless_window_elements(
state: &Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
output: &Output, output: &Output,
) -> Vec<AnimatedWindowRenderElement> { ) -> Vec<AnimatedWindowRenderElement> {
@@ -232,7 +247,7 @@ pub(crate) fn cursorless_window_elements(
} }
fn output_elements( fn output_elements(
state: &Ferese, state: &mut Ferese,
renderer: &mut GlesRenderer, renderer: &mut GlesRenderer,
output: &Output, output: &Output,
include_cursor: bool, include_cursor: bool,
@@ -252,14 +267,24 @@ fn output_elements(
output, output,
&[Layer::Overlay, Layer::Top], &[Layer::Overlay, Layer::Top],
)); ));
elements.extend( let windows = state
state
.space .space
.elements() .elements()
.rev() .rev()
.filter_map(|window| { .filter_map(|window| {
let id = state.window_ids.get(window)?; let id = *state.window_ids.get(window)?;
let visual = state.window_geometry.get(id)?.visual.current; 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((window.clone(), id, visual, fullscreen))
})
.collect::<Vec<_>>();
for (window, id, visual, fullscreen) in windows {
let constrain = Rectangle::<i32, Logical>::new( let constrain = Rectangle::<i32, Logical>::new(
( (
(visual.x - f64::from(output_geometry.loc.x)).round() as i32, (visual.x - f64::from(output_geometry.loc.x)).round() as i32,
@@ -273,13 +298,16 @@ fn output_elements(
.into(), .into(),
); );
Some(constrain_space_element::< if !fullscreen {
elements.extend(window_border_elements(state, id, constrain, scale));
}
elements.extend(constrain_space_element::<
GlesRenderer, GlesRenderer,
_, _,
AnimatedWindowRenderElement, AnimatedWindowRenderElement,
>( >(
renderer, renderer,
window, &window,
constrain.loc, constrain.loc,
1.0, 1.0,
scale, scale,
@@ -289,10 +317,8 @@ fn output_elements(
behavior: ConstrainScaleBehavior::Stretch, behavior: ConstrainScaleBehavior::Stretch,
align: ConstrainAlign::TOP | ConstrainAlign::LEFT, align: ConstrainAlign::TOP | ConstrainAlign::LEFT,
}, },
)) ));
}) }
.flatten(),
);
elements.extend(layer_elements( elements.extend(layer_elements(
renderer, renderer,
output, output,
@@ -301,6 +327,88 @@ fn output_elements(
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>( pub(crate) fn redraw_output<R, E>(
renderer: &mut R, renderer: &mut R,
framebuffer: &mut R::Framebuffer<'_>, framebuffer: &mut R::Framebuffer<'_>,
@@ -465,7 +573,9 @@ fn normalized_scale(scale: f64) -> f64 {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::normalized_scale; use smithay::utils::{Logical, Rectangle};
use super::{border_rectangles, normalized_scale};
#[test] #[test]
fn accepts_positive_finite_scale() { fn accepts_positive_finite_scale() {
@@ -478,4 +588,32 @@ mod tests {
assert_eq!(normalized_scale(f64::NAN), 1.0); assert_eq!(normalized_scale(f64::NAN), 1.0);
assert_eq!(normalized_scale(f64::INFINITY), 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))
);
}
} }