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
```
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
+177 -1
View File
@@ -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(
+1
View File
@@ -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()?,
+7 -1
View File
@@ -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) {
+152 -14
View File
@@ -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,14 +267,24 @@ fn output_elements(
output,
&[Layer::Overlay, Layer::Top],
));
elements.extend(
state
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 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((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,
@@ -273,13 +298,16 @@ fn output_elements(
.into(),
);
Some(constrain_space_element::<
if !fullscreen {
elements.extend(window_border_elements(state, id, constrain, scale));
}
elements.extend(constrain_space_element::<
GlesRenderer,
_,
AnimatedWindowRenderElement,
>(
renderer,
window,
&window,
constrain.loc,
1.0,
scale,
@@ -289,10 +317,8 @@ fn output_elements(
behavior: ConstrainScaleBehavior::Stretch,
align: ConstrainAlign::TOP | ConstrainAlign::LEFT,
},
))
})
.flatten(),
);
));
}
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))
);
}
}