atagen

monolith

public
Star0
Watch0

Commit 618afeb78754

tarball

atagen <boss@atagen.co>2026-10-17 13:45 UTC

5 files changed+131-52
monolith: fade scratchpads and focus on hover only when entering a window

Summoning plays the spawn fade and dismissing leaves a still ghost of the last frame fading where the window was. Focus used to follow whatever window was under the pointer on every motion, so a new window lost focus the moment the mouse twitched over an old one.
Modified file: crates/monolith/src/anim.rs+10 additions−4 deletions
@@ -26,6 +26,9 @@ pub struct Ghost {
2626 pub output: OutputId,
2727 pub seed: Rect,
2828 pub born: u64,
29+ /// Fades where it is for one phase instead of shrinking in the plan,
30+ /// like a dismissed scratchpad.
31+ pub fixed: Option<Rect>,
2932 }
3033
3134 struct Track {
@@ -84,7 +87,7 @@ impl Animator {
8487 let ghosts = self
8588 .ghosts
8689 .iter()
87- .filter(|g| g.output == o)
90+ .filter(|g| g.output == o && g.fixed.is_none())
8891 .map(|g| (g.id, g.seed));
8992 let targets: Vec<(WindowId, Rect)> = ghosts.chain(targets.iter().copied()).collect();
9093 let ids: Vec<WindowId> = targets.iter().map(|(id, _)| *id).collect();
@@ -199,6 +202,7 @@ impl Animator {
199202 }
200203
201204 pub fn spawned(&mut self, id: WindowId, now: u64) {
205+ self.ghosts.retain(|g| g.id != id);
202206 if self.phase_ns() > 0 {
203207 self.spawns.insert(id, now);
204208 }
@@ -229,6 +233,7 @@ impl Animator {
229233 self.tracks.values().any(|t| t.timeline.end_ns() > now)
230234 || self.glides.values().any(|g| g.timeline.end_ns() > now)
231235 || !self.spawns.is_empty()
236+ || self.ghosts.iter().any(|g| g.fixed.is_some())
232237 }
233238
234239 pub fn forget(&mut self, id: WindowId) {
@@ -242,10 +247,11 @@ impl Animator {
242247 self.spawns
243248 .retain(|_, start| now.saturating_sub(*start) < phase);
244249 let tracks = &self.tracks;
245- self.ghosts.retain(|g| {
246- tracks
250+ self.ghosts.retain(|g| match g.fixed {
251+ Some(_) => now.saturating_sub(g.born) < phase,
252+ None => tracks
247253 .get(&g.output)
248- .is_some_and(|t| t.timeline.end_ns() > now)
254+ .is_some_and(|t| t.timeline.end_ns() > now),
249255 });
250256 }
251257 }
Modified file: crates/monolith/src/input.rs+12 additions−3 deletions
@@ -20,7 +20,7 @@ impl State {
2020 let Some(pointer) = self.input.seat.get_pointer() else {
2121 return;
2222 };
23- focus_on_hover(self, pos);
23+ focus_on_hover(self, pos, self.input.warping);
2424 let under = self.surface_under(pos);
2525 pointer.motion(
2626 self,
@@ -166,7 +166,10 @@ fn window_at(state: &State, pos: Point<f64, Logical>) -> Option<WindowId> {
166166 }
167167
168168 /// Layers above windows, like a bar, keep the focus where it was.
169-fn focus_on_hover(state: &mut State, pos: Point<f64, Logical>) {
169+/// Focus changes only when the pointer enters another window. Moving
170+/// inside one, or being warped, leaves focus alone, so a new window keeps
171+/// it until the pointer actually goes somewhere else.
172+fn focus_on_hover(state: &mut State, pos: Point<f64, Logical>, warped: bool) {
170173 if !state.settings.config.general.focus_follows_mouse
171174 || state.services.lock.active()
172175 || state
@@ -175,7 +178,11 @@ fn focus_on_hover(state: &mut State, pos: Point<f64, Logical>) {
175178 {
176179 return;
177180 }
178- let Some(id) = window_at(state, pos) else {
181+ let under = window_at(state, pos);
182+ if std::mem::replace(&mut state.input.hovered, under) == under || warped {
183+ return;
184+ }
185+ let Some(id) = under else {
179186 return;
180187 };
181188 if state.wm.layout.focused() == Some(id) {
@@ -202,10 +209,12 @@ impl State {
202209 let Some((_, r, _)) = focused.and_then(|id| self.wm.get(id)?.placed) else {
203210 return;
204211 };
212+ self.input.warping = true;
205213 self.pointer_to(
206214 Point::from((r.x + r.w / 2.0, r.y + r.h / 2.0)),
207215 InputTime::now(),
208216 );
217+ self.input.warping = false;
209218 self.pointer_frame();
210219 self.dirty.cursor = true;
211220 }
Modified file: crates/monolith/src/scene.rs+37 additions−23 deletions
@@ -1,7 +1,7 @@
11 //! What a frame draws. The space holds final geometry for input, while the
22 //! scene draws windows where the animator says they are right now.
33
4-use crate::wm::Wm;
4+use crate::wm::{LastFrame, Wm};
55 use monolith_config::Decoration;
66 use monolith_layout::{OutputId, Rect};
77 use monolith_render_vk::{Clip, Effect, VulkanFrame, VulkanRenderer, VulkanTexture};
@@ -59,9 +59,18 @@ pub fn build(
5959 let mut ghosts = Vec::new();
6060 let mut tiled = Vec::new();
6161 let mut above = Vec::new();
62+ let mut fading = Vec::new();
6263 for g in anim.ghosts().iter().filter(|g| g.output == o) {
63- if let (Some(r), Some(frame)) = (anim.tiled(o, g.id, now), last_frames.get(&g.id)) {
64- ghosts.push((g, frame, local(r)));
64+ let Some(frame) = last_frames.get(&g.id) else {
65+ continue;
66+ };
67+ match g.fixed {
68+ Some(r) => fading.push((g, frame, local(r))),
69+ None => {
70+ if let Some(r) = anim.tiled(o, g.id, now) {
71+ ghosts.push((g, frame, local(r)));
72+ }
73+ }
6574 }
6675 }
6776 let arr = layout.arrange(o);
@@ -108,6 +117,9 @@ pub fn build(
108117 crate::layers::elements(&mut out, renderer, output, Layer::Top);
109118 }
110119 }
120+ for (g, frame, r) in fading.into_iter().rev() {
121+ out.extend(ghost_element(frame, r, anim.ghost_effect(g, now), deco));
122+ }
111123 for (id, r, framed, gliding) in tiled.into_iter().chain(above).rev() {
112124 let Some(state) = windows.get_mut(id) else {
113125 continue;
@@ -135,26 +147,7 @@ pub fn build(
135147 }
136148 }
137149 for (g, frame, r) in ghosts.into_iter().rev() {
138- let Some((at, size)) = pixels(r) else {
139- continue;
140- };
141- let src = Rectangle::new(
142- frame.geometry.loc.to_f64(),
143- (f64::from(size.0), f64::from(size.1)).into(),
144- );
145- let ghost = Draw::Effect(EffectElement::new(
146- &frame.texture,
147- frame.scale,
148- frame.transform,
149- src,
150- at,
151- size,
152- anim.ghost_effect(g, now),
153- ));
154- out.push(match corner_clip(r, deco) {
155- Some(clip) => Draw::Clipped(ClipElement::new(ghost, clip)),
156- None => ghost,
157- });
150+ out.extend(ghost_element(frame, r, anim.ghost_effect(g, now), deco));
158151 }
159152 if let Some(output) = output {
160153 crate::layers::elements(&mut out, renderer, output, Layer::Bottom);
@@ -437,6 +430,27 @@ impl RenderElement<VulkanRenderer> for EffectElement {
437430 }
438431 }
439432
433+fn ghost_element(frame: &LastFrame, r: Rect, effect: Effect, deco: &Decoration) -> Option<Draw> {
434+ let (at, size) = pixels(r)?;
435+ let src = Rectangle::new(
436+ frame.geometry.loc.to_f64(),
437+ (f64::from(size.0), f64::from(size.1)).into(),
438+ );
439+ let ghost = Draw::Effect(EffectElement::new(
440+ &frame.texture,
441+ frame.scale,
442+ frame.transform,
443+ src,
444+ at,
445+ size,
446+ effect,
447+ ));
448+ Some(match corner_clip(r, deco) {
449+ Some(clip) => Draw::Clipped(ClipElement::new(ghost, clip)),
450+ None => ghost,
451+ })
452+}
453+
440454 /// The rounded outline of a framed window at `r`, if it has one.
441455 fn corner_clip(r: Rect, deco: &Decoration) -> Option<Clip> {
442456 let radii = deco.radius.0;
Modified file: crates/monolith/src/state.rs+30 additions
@@ -52,6 +52,9 @@ pub struct Input {
5252 /// The focus mouse-follows-focus last saw, so pointer-made focus
5353 /// changes can mark themselves as already followed.
5454 pub followed: Option<WindowId>,
55+ /// The window under the pointer when it last moved.
56+ pub hovered: Option<WindowId>,
57+ pub warping: bool,
5558 pub cursor: crate::cursor::Cursor,
5659 pub cursor_status: CursorImageStatus,
5760 }
@@ -176,6 +179,8 @@ impl State {
176179 drag: None,
177180 move_inverted: false,
178181 followed: None,
182+ hovered: None,
183+ warping: false,
179184 cursor: crate::cursor::Cursor::load(),
180185 cursor_status: CursorImageStatus::default_named(),
181186 },
@@ -317,6 +322,31 @@ impl State {
317322 for (id, from, to) in glides {
318323 self.wm.anim.glide(id, from, to, now);
319324 }
325+ let scratch = |wm: &Wm, id| wm.layout.window(id).is_some_and(|w| w.scratchpad.is_some());
326+ let summoned: Vec<WindowId> = shown
327+ .iter()
328+ .filter(|&&(id, _, kind, _)| {
329+ kind == Kind::Floating
330+ && scratch(&self.wm, id)
331+ && self
332+ .wm
333+ .get(id)
334+ .is_some_and(|s| s.placed.is_none() && !s.awaiting_buffer)
335+ })
336+ .map(|s| s.0)
337+ .collect();
338+ let dismissed: Vec<(WindowId, Rect, OutputId)> = self
339+ .wm
340+ .iter()
341+ .filter(|&(id, _)| scratch(&self.wm, id) && !shown.iter().any(|s| s.0 == id))
342+ .filter_map(|(id, s)| s.placed.map(|(_, r, o)| (id, r, o)))
343+ .collect();
344+ for id in summoned {
345+ self.wm.anim.spawned(id, now);
346+ }
347+ for (id, r, o) in dismissed {
348+ self.fade_out(id, r, o);
349+ }
320350 for (_, s) in self.wm.iter_mut() {
321351 s.placed = None;
322352 }
Modified file: crates/monolith/src/windows.rs+42 additions−22 deletions
@@ -1,7 +1,7 @@
11 use crate::anim::Ghost;
22 use crate::state::{State, now_ns};
33 use crate::wm::{LastFrame, identity, root};
4-use monolith_layout::{Rect, WindowId};
4+use monolith_layout::{OutputId, Rect, WindowId};
55 use monolith_render_vk::VulkanTexture;
66 use smithay::backend::renderer::Renderer;
77 use smithay::backend::renderer::utils::{import_surface_tree, with_renderer_surface_state};
@@ -146,36 +146,56 @@ impl State {
146146 else {
147147 return;
148148 };
149- let Some(surface) = root(window) else {
150- return;
151- };
152- let _ = import_surface_tree(&mut self.renderer, &surface);
153- let context = self.renderer.context_id();
154- let snapshot = with_renderer_surface_state(&surface, |s| {
155- s.texture::<VulkanTexture>(context.clone())
156- .cloned()
157- .map(|t| (t, s.buffer_scale(), s.buffer_transform()))
158- })
159- .flatten();
160- if let Some((texture, scale, transform)) = snapshot {
161- self.wm.last_frames.insert(
162- id,
163- LastFrame {
164- texture,
165- scale,
166- transform,
167- geometry: window.geometry(),
168- },
169- );
149+ if let Some(frame) = self.last_frame(window) {
150+ self.wm.last_frames.insert(id, frame);
170151 self.wm.anim.add_ghost(Ghost {
171152 id,
172153 output: o,
173154 seed: seed.rect,
174155 born: now_ns(),
156+ fixed: None,
157+ });
158+ }
159+ }
160+
161+ /// Fades a window that just left the screen where it was, like a
162+ /// dismissed scratchpad.
163+ pub fn fade_out(&mut self, id: WindowId, rect: Rect, output: OutputId) {
164+ let Some(window) = self.wm.window(id).cloned() else {
165+ return;
166+ };
167+ if let Some(frame) = self.last_frame(&window) {
168+ self.wm.last_frames.insert(id, frame);
169+ self.wm.anim.add_ghost(Ghost {
170+ id,
171+ output,
172+ seed: rect,
173+ born: now_ns(),
174+ fixed: Some(rect),
175175 });
176176 }
177177 }
178178
179+ fn last_frame(&mut self, window: &Window) -> Option<LastFrame> {
180+ let surface = root(window)?;
181+ let _ = import_surface_tree(&mut self.renderer, &surface);
182+ let context = self.renderer.context_id();
183+ // The geometry lookup locks the surface's state, which the closure
184+ // below already holds.
185+ let geometry = window.geometry();
186+ with_renderer_surface_state(&surface, |s| {
187+ s.texture::<VulkanTexture>(context.clone())
188+ .cloned()
189+ .map(|texture| LastFrame {
190+ texture,
191+ scale: s.buffer_scale(),
192+ transform: s.buffer_transform(),
193+ geometry,
194+ })
195+ })
196+ .flatten()
197+ }
198+
179199 /// Picks up a changed title or app id, which clients send at any time.
180200 pub fn refresh_identity(&mut self, id: WindowId) {
181201 let Some(w) = self.wm.get_mut(id) else {