atagen

monolith

public
Star0
Watch0

Commit 7ce6ae4e5a85

tarball

atagen <boss@atagen.co>2026-10-16 12:17 UTC

5 files changed+129-8
monolith: draw borders around windows

Clients are told decorations are server side, and monolith drew nothing in their place, so the focused window could not be told apart. Borders sit in the gap outside the window so client sizes are unaffected, follow the animated rect, and fade in with a spawning window.
Modified file: crates/config/default.toml+6 additions
@@ -13,6 +13,12 @@ gaps = { inner = 8, outer = 8 }
1313 # merge joins its lane. invert-move does the other one for the next move.
1414 move = "swap"
1515
16+[decoration]
17+# Borders sit in the gap around each window, so keep them under half the inner gap.
18+border = 2
19+focused = "#88bfd1"
20+unfocused = "#3b4252"
21+
1622 [animation]
1723 enabled = true
1824 # orthogonal moves each window along one axis per phase, direct allows both.
Modified file: crates/config/src/lib.rs+45 additions
@@ -12,6 +12,7 @@ pub const DEFAULT: &str = include_str!("../default.toml");
1212 #[derive(Debug, Clone)]
1313 pub struct Config {
1414 pub general: General,
15+ pub decoration: Decoration,
1516 pub animation: Animation,
1617 pub keyboard: Keyboard,
1718 pub binds: Vec<Bind>,
@@ -69,6 +70,47 @@ impl Default for Gaps {
6970
7071 #[derive(Deserialize, Debug, Clone, PartialEq)]
7172 #[serde(default, deny_unknown_fields)]
73+pub struct Decoration {
74+ /// Border width in logical pixels, drawn in the gap around each window.
75+ pub border: u32,
76+ pub focused: Color,
77+ pub unfocused: Color,
78+}
79+
80+impl Default for Decoration {
81+ fn default() -> Self {
82+ Self {
83+ border: 2,
84+ focused: Color([0.53, 0.75, 0.82, 1.0]),
85+ unfocused: Color([0.23, 0.26, 0.32, 1.0]),
86+ }
87+ }
88+}
89+
90+/// Straight RGBA from `#rrggbb` or `#rrggbbaa`.
91+#[derive(Debug, Clone, Copy, PartialEq)]
92+pub struct Color(pub [f32; 4]);
93+
94+impl<'de> Deserialize<'de> for Color {
95+ fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
96+ let s = String::deserialize(d)?;
97+ let hex = s.strip_prefix('#').unwrap_or(&s);
98+ let bad =
99+ || serde::de::Error::custom(format!("bad colour {s:?}, use #rrggbb or #rrggbbaa"));
100+ if !matches!(hex.len(), 6 | 8) || !hex.is_ascii() {
101+ return Err(bad());
102+ }
103+ let mut c = [1.0f32; 4];
104+ for (i, v) in c.iter_mut().enumerate().take(hex.len() / 2) {
105+ let byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| bad())?;
106+ *v = f32::from(byte) / 255.0;
107+ }
108+ Ok(Self(c))
109+ }
110+}
111+
112+#[derive(Deserialize, Debug, Clone, PartialEq)]
113+#[serde(default, deny_unknown_fields)]
72114 pub struct Animation {
73115 pub enabled: bool,
74116 pub style: AnimationStyle,
@@ -212,6 +254,8 @@ struct Raw {
212254 #[serde(default)]
213255 animation: Animation,
214256 #[serde(default)]
257+ decoration: Decoration,
258+ #[serde(default)]
215259 binds: BTreeMap<String, Words>,
216260 #[serde(default)]
217261 rules: Vec<RawRule>,
@@ -290,6 +334,7 @@ impl Config {
290334 Ok(Self {
291335 general: raw.general,
292336 animation: raw.animation,
337+ decoration: raw.decoration,
293338 keyboard: raw.keyboard,
294339 binds,
295340 rules,
Modified file: crates/monolith/src/nested/mod.rs+11 additions−1 deletions
@@ -176,6 +176,8 @@ pub fn render(state: &mut State) {
176176 layout,
177177 windows,
178178 anim,
179+ borders,
180+ config,
179181 ..
180182 } = state;
181183 let Some(host) = host.as_mut() else { return };
@@ -219,7 +221,15 @@ pub fn render(state: &mut State) {
219221 return;
220222 }
221223 };
222- let elements = crate::scene::build(layout, windows, anim, renderer, now);
224+ let elements = crate::scene::build(
225+ layout,
226+ windows,
227+ anim,
228+ renderer,
229+ now,
230+ &config.decoration,
231+ borders,
232+ );
223233 match host
224234 .damage
225235 .render_output(renderer, &mut fb, slot.age() as usize, &elements, CLEAR)
Modified file: crates/monolith/src/scene.rs+65 additions−7 deletions
@@ -2,8 +2,10 @@
22 //! scene draws windows where the animator says they are right now.
33
44 use crate::anim::Animator;
5+use monolith_config::Decoration;
56 use monolith_layout::{Layout, Rect, WindowId};
67 use monolith_render_vk::{Effect, VulkanFrame, VulkanRenderer, VulkanTexture};
8+use smithay::backend::renderer::element::solid::{SolidColorBuffer, SolidColorRenderElement};
79 use smithay::backend::renderer::element::surface::WaylandSurfaceRenderElement;
810 use smithay::backend::renderer::element::texture::TextureRenderElement;
911 use smithay::backend::renderer::element::utils::CropRenderElement;
@@ -22,8 +24,14 @@ smithay::backend::renderer::element::render_elements! {
2224 Cropped=CropRenderElement<WaylandSurfaceRenderElement<VulkanRenderer>>,
2325 Texture=TextureRenderElement<VulkanTexture>,
2426 Effect=EffectElement,
27+ Border=SolidColorRenderElement,
2528 }
2629
30+/// Border strips per window, kept between frames so unchanged borders cause
31+/// no damage.
32+#[derive(Default)]
33+pub struct Borders(HashMap<WindowId, [SolidColorBuffer; 4]>);
34+
2735 /// Elements for every output, front to back.
2836 pub fn build(
2937 layout: &Layout,
@@ -31,6 +39,8 @@ pub fn build(
3139 anim: &Animator,
3240 renderer: &mut VulkanRenderer,
3341 now: u64,
42+ deco: &Decoration,
43+ borders: &mut Borders,
3444 ) -> Vec<Draw> {
3545 let mut ghosts = Vec::new();
3646 let mut tiled = Vec::new();
@@ -50,24 +60,37 @@ pub fn build(
5060 continue;
5161 }
5262 match anim.gliding(p.window, now) {
53- Some(r) => above.push((p.window, r)),
63+ Some(r) => above.push((p.window, r, true)),
5464 None => {
5565 let r = anim.tiled(o, p.window, now).unwrap_or(p.rect);
56- tiled.push((p.window, r));
66+ tiled.push((p.window, r, true));
5767 }
5868 }
5969 }
6070 for &(w, r) in &arr.floating {
61- above.push((w, anim.gliding(w, now).unwrap_or(r)));
71+ above.push((w, anim.gliding(w, now).unwrap_or(r), true));
6272 }
6373 if let Some(w) = arr.fullscreen {
64- above.push((w, anim.gliding(w, now).unwrap_or(out.area)));
74+ above.push((w, anim.gliding(w, now).unwrap_or(out.area), false));
6575 }
6676 }
77+ borders.0.retain(|id, _| windows.contains_key(id));
78+ let focused = layout.focused();
6779 let mut out = Vec::new();
68- for (id, r) in tiled.into_iter().chain(above).rev() {
69- if let Some(window) = windows.get(&id) {
70- window_elements(&mut out, renderer, window, r, anim.effect(id, now));
80+ for (id, r, framed) in tiled.into_iter().chain(above).rev() {
81+ let Some(window) = windows.get(&id) else {
82+ continue;
83+ };
84+ let effect = anim.effect(id, now);
85+ window_elements(&mut out, renderer, window, r, effect);
86+ if framed && deco.border > 0 {
87+ let color = if focused == Some(id) {
88+ deco.focused.0
89+ } else {
90+ deco.unfocused.0
91+ };
92+ let alpha = effect.map_or(1.0, |e| e.progress);
93+ border_elements(&mut out, borders, id, r, deco.border as i32, color, alpha);
7194 }
7295 }
7396 for (g, r) in ghosts.into_iter().rev() {
@@ -163,6 +186,41 @@ fn window_elements(
163186 );
164187 }
165188
189+/// Four strips just outside `r`, in the gap, so the client keeps its size.
190+fn border_elements(
191+ out: &mut Vec<Draw>,
192+ borders: &mut Borders,
193+ id: WindowId,
194+ r: Rect,
195+ b: i32,
196+ color: [f32; 4],
197+ alpha: f32,
198+) {
199+ let Some(((x, y), (w, h))) = pixels(r) else {
200+ return;
201+ };
202+ let strips = [
203+ ((x - b, y - b), (w + 2 * b, b)),
204+ ((x - b, y + h), (w + 2 * b, b)),
205+ ((x - b, y), (b, h)),
206+ ((x + w, y), (b, h)),
207+ ];
208+ let buffers = borders
209+ .0
210+ .entry(id)
211+ .or_insert_with(|| std::array::from_fn(|_| SolidColorBuffer::new((0, 0), color)));
212+ for (buffer, (at, size)) in buffers.iter_mut().zip(strips) {
213+ buffer.update(size, color);
214+ out.push(Draw::Border(SolidColorRenderElement::from_buffer(
215+ buffer,
216+ at,
217+ 1.0,
218+ alpha,
219+ Kind::Unspecified,
220+ )));
221+ }
222+}
223+
166224 /// Snaps a logical rect to whole pixels by rounding its edges, so neighbours
167225 /// that share an edge stay flush.
168226 fn pixels(r: Rect) -> Option<((i32, i32), (i32, i32))> {
Modified file: crates/monolith/src/state.rs+2 additions
@@ -62,6 +62,7 @@ pub struct State {
6262 pub launches: crate::launch::Launches,
6363 pub workspaces: WorkspaceState,
6464 pub anim: Animator,
65+ pub borders: crate::scene::Borders,
6566 /// How each shown window was placed by the last layout pass, to spot
6667 /// switches between tiled, floating and fullscreen.
6768 pub last_kind: HashMap<WindowId, (Kind, Rect, OutputId)>,
@@ -147,6 +148,7 @@ impl State {
147148 launches: Default::default(),
148149 workspaces: WorkspaceState::new::<State>(&dh),
149150 anim: Animator::new(animation),
151+ borders: Default::default(),
150152 last_kind: HashMap::new(),
151153 compositor_state: CompositorState::new::<State>(&dh),
152154 xdg_shell_state: XdgShellState::new::<State>(&dh),