gardesk/gar / 9239558

Browse files

Add floating window move/resize with mod+drag and edge hover

- Support both Alt and Super mod keys for drag operations
- Add resize cursors for all edges and corners
- Change cursor on hover near floating window edges
- Click and drag edges to resize without mod key
- Keep button grabs on floating windows for edge detection
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
923955868720226f505166ce5205a2245b99900b
Parents
34fe404
Tree
9558cb8

3 changed files

StatusFile+-
M gar/src/core/mod.rs 8 1
M gar/src/x11/connection.rs 115 32
M gar/src/x11/events.rs 274 45
gar/src/core/mod.rsmodified
@@ -43,6 +43,8 @@ pub struct WindowManager {
4343
     pub focus_history: Vec<XWindow>,
4444
     /// Struts from dock windows (status bars) - maps window ID to strut
4545
     pub dock_struts: HashMap<XWindow, Strut>,
46
+    /// Current edge being displayed (for cursor changes on floating window edges)
47
+    pub current_edge_cursor: Option<(XWindow, crate::x11::events::ResizeEdge)>,
4648
 }
4749
 
4850
 impl WindowManager {
@@ -132,6 +134,7 @@ impl WindowManager {
132134
             frames: FrameManager::new(),
133135
             focus_history: Vec::new(),
134136
             dock_struts: HashMap::new(),
137
+            current_edge_cursor: None,
135138
         })
136139
     }
137140
 
@@ -495,7 +498,11 @@ impl WindowManager {
495498
         self.current_workspace_mut().focused = Some(window);
496499
 
497500
         // Ungrab buttons on new focused window (allow clicks through)
498
-        let _ = self.conn.ungrab_button(window);
501
+        // But keep grabs on floating windows so we can detect edge resize clicks
502
+        let is_floating = self.windows.get(&window).map_or(false, |w| w.floating);
503
+        if !is_floating {
504
+            let _ = self.conn.ungrab_button(window);
505
+        }
499506
 
500507
         // Update focus history - move window to front
501508
         self.focus_history.retain(|&w| w != window);
gar/src/x11/connection.rsmodified
@@ -83,6 +83,16 @@ pub struct Connection {
8383
     // Struts (reserved screen areas for docks/panels)
8484
     pub net_wm_strut: Atom,
8585
     pub net_wm_strut_partial: Atom,
86
+    // Cursors for resize operations
87
+    pub cursor_normal: u32,
88
+    pub cursor_top_left: u32,
89
+    pub cursor_top_right: u32,
90
+    pub cursor_bottom_left: u32,
91
+    pub cursor_bottom_right: u32,
92
+    pub cursor_left: u32,
93
+    pub cursor_right: u32,
94
+    pub cursor_top: u32,
95
+    pub cursor_bottom: u32,
8696
 }
8797
 
8898
 impl Connection {
@@ -142,6 +152,19 @@ impl Connection {
142152
         let net_wm_strut = conn.intern_atom(false, b"_NET_WM_STRUT")?.reply()?.atom;
143153
         let net_wm_strut_partial = conn.intern_atom(false, b"_NET_WM_STRUT_PARTIAL")?.reply()?.atom;
144154
 
155
+        // Create cursors for pointer and resize operations
156
+        let (
157
+            cursor_normal,
158
+            cursor_top_left,
159
+            cursor_top_right,
160
+            cursor_bottom_left,
161
+            cursor_bottom_right,
162
+            cursor_left,
163
+            cursor_right,
164
+            cursor_top,
165
+            cursor_bottom,
166
+        ) = Self::create_cursors(&conn)?;
167
+
145168
         tracing::info!(
146169
             "Connected to X server, screen {}x{}",
147170
             screen_width,
@@ -185,6 +208,15 @@ impl Connection {
185208
             net_wm_bypass_compositor,
186209
             net_wm_strut,
187210
             net_wm_strut_partial,
211
+            cursor_normal,
212
+            cursor_top_left,
213
+            cursor_top_right,
214
+            cursor_bottom_left,
215
+            cursor_bottom_right,
216
+            cursor_left,
217
+            cursor_right,
218
+            cursor_top,
219
+            cursor_bottom,
188220
         })
189221
     }
190222
 
@@ -193,10 +225,6 @@ impl Connection {
193225
     }
194226
 
195227
     pub fn become_wm(&self) -> Result<(), Error> {
196
-        // Create a normal pointer cursor for the root window
197
-        // This prevents the ugly X cursor when no windows are focused
198
-        let cursor = self.create_cursor()?;
199
-
200228
         // Set root window background to black, cursor, and subscribe to events
201229
         // The background ensures old window pixels are cleared when windows close
202230
         let change = ChangeWindowAttributesAux::new()
@@ -207,7 +235,7 @@ impl Connection {
207235
                     | EventMask::PROPERTY_CHANGE,
208236
             )
209237
             .background_pixel(self.screen().black_pixel)
210
-            .cursor(cursor);
238
+            .cursor(self.cursor_normal);
211239
 
212240
         let result = self
213241
             .conn
@@ -254,29 +282,55 @@ impl Connection {
254282
         }
255283
     }
256284
 
257
-    /// Create a left pointer cursor from the cursor font.
258
-    fn create_cursor(&self) -> Result<u32, Error> {
285
+    /// Create all cursors used by the window manager.
286
+    fn create_cursors(conn: &RustConnection) -> Result<(u32, u32, u32, u32, u32, u32, u32, u32, u32), Error> {
259287
         // Open the cursor font
260
-        let font: Font = self.conn.generate_id()?;
261
-        self.conn.open_font(font, b"cursor")?;
262
-
263
-        // Create cursor from font glyphs
264
-        // left_ptr is glyph 68, its mask is glyph 69
265
-        let cursor = self.conn.generate_id()?;
266
-        self.conn.create_glyph_cursor(
267
-            cursor,
268
-            font,
269
-            font,
270
-            68,  // left_ptr glyph
271
-            69,  // mask glyph
272
-            0, 0, 0,           // foreground RGB (black)
273
-            0xFFFF, 0xFFFF, 0xFFFF,  // background RGB (white)
274
-        )?;
275
-
276
-        // Close font (cursor keeps its own reference)
277
-        self.conn.close_font(font)?;
278
-
279
-        Ok(cursor)
288
+        let font: Font = conn.generate_id()?;
289
+        conn.open_font(font, b"cursor")?;
290
+
291
+        // Cursor glyph numbers from the cursor font:
292
+        // left_ptr = 68, top_left_corner = 134, top_right_corner = 136
293
+        // bottom_left_corner = 12, bottom_right_corner = 14
294
+        // left_side = 70, right_side = 96, top_side = 138, bottom_side = 16
295
+
296
+        let create = |glyph: u16| -> Result<u32, Error> {
297
+            let cursor = conn.generate_id()?;
298
+            conn.create_glyph_cursor(
299
+                cursor,
300
+                font,
301
+                font,
302
+                glyph,
303
+                glyph + 1,
304
+                0, 0, 0,
305
+                0xFFFF, 0xFFFF, 0xFFFF,
306
+            )?;
307
+            Ok(cursor)
308
+        };
309
+
310
+        let cursor_normal = create(68)?;       // left_ptr
311
+        let cursor_top_left = create(134)?;    // top_left_corner
312
+        let cursor_top_right = create(136)?;   // top_right_corner
313
+        let cursor_bottom_left = create(12)?;  // bottom_left_corner
314
+        let cursor_bottom_right = create(14)?; // bottom_right_corner
315
+        let cursor_left = create(70)?;         // left_side
316
+        let cursor_right = create(96)?;        // right_side
317
+        let cursor_top = create(138)?;         // top_side
318
+        let cursor_bottom = create(16)?;       // bottom_side
319
+
320
+        // Close font (cursors keep their own references)
321
+        conn.close_font(font)?;
322
+
323
+        Ok((
324
+            cursor_normal,
325
+            cursor_top_left,
326
+            cursor_top_right,
327
+            cursor_bottom_left,
328
+            cursor_bottom_right,
329
+            cursor_left,
330
+            cursor_right,
331
+            cursor_top,
332
+            cursor_bottom,
333
+        ))
280334
     }
281335
 
282336
     /// Grab a key combination on the root window.
@@ -330,13 +384,15 @@ impl Connection {
330384
         Ok(())
331385
     }
332386
 
333
-    /// Grab Alt+Button1 and Alt+Button3 on root for floating window move/resize.
387
+    /// Grab Mod+Button1 and Mod+Button3 on root for floating window move/resize.
388
+    /// Grabs both Alt (M1) and Super (M4) to support either mod key configuration.
334389
     pub fn grab_mod_buttons(&self) -> Result<(), Error> {
335390
         let numlock = ModMask::M2;
336391
         let capslock = ModMask::LOCK;
337392
 
338
-        // Grab Alt+Button1 (move) and Alt+Button3 (resize) with NumLock/CapsLock variants
393
+        // Grab both Alt (M1) and Super (M4) with Button1 (move) and Button3 (resize)
339394
         for button in [ButtonIndex::M1, ButtonIndex::M3] {
395
+            // Alt variants
340396
             for mods in [
341397
                 ModMask::M1,
342398
                 ModMask::M1 | numlock,
@@ -355,8 +411,34 @@ impl Connection {
355411
                     mods,
356412
                 )?;
357413
             }
414
+            // Super variants
415
+            for mods in [
416
+                ModMask::M4,
417
+                ModMask::M4 | numlock,
418
+                ModMask::M4 | capslock,
419
+                ModMask::M4 | numlock | capslock,
420
+            ] {
421
+                self.conn.grab_button(
422
+                    false,
423
+                    self.root,
424
+                    EventMask::BUTTON_PRESS | EventMask::BUTTON_RELEASE | EventMask::BUTTON_MOTION,
425
+                    GrabMode::ASYNC,
426
+                    GrabMode::ASYNC,
427
+                    x11rb::NONE,
428
+                    x11rb::NONE,
429
+                    button,
430
+                    mods,
431
+                )?;
432
+            }
358433
         }
359
-        tracing::debug!("Grabbed Alt+Button1/Button3 on root for floating move/resize");
434
+        tracing::debug!("Grabbed Mod+Button1/Button3 on root for floating move/resize");
435
+        Ok(())
436
+    }
437
+
438
+    /// Set the cursor for a window.
439
+    pub fn set_window_cursor(&self, window: Window, cursor: u32) -> Result<(), Error> {
440
+        let change = ChangeWindowAttributesAux::new().cursor(cursor);
441
+        self.conn.change_window_attributes(window, &change)?;
360442
         Ok(())
361443
     }
362444
 
@@ -448,7 +530,7 @@ impl Connection {
448530
 
449531
     /// Grab the pointer for drag operations.
450532
     pub fn grab_pointer(&self, _window: Window) -> Result<(), Error> {
451
-        self.conn.grab_pointer(
533
+        let reply = self.conn.grab_pointer(
452534
             false,
453535
             self.root,
454536
             EventMask::BUTTON_RELEASE | EventMask::POINTER_MOTION,
@@ -457,7 +539,8 @@ impl Connection {
457539
             x11rb::NONE,
458540
             x11rb::NONE,
459541
             CURRENT_TIME,
460
-        )?;
542
+        )?.reply()?;
543
+        tracing::debug!("grab_pointer result: {:?}", reply.status);
461544
         Ok(())
462545
     }
463546
 
gar/src/x11/events.rsmodified
@@ -31,12 +31,17 @@ pub enum DragState {
3131
     },
3232
 }
3333
 
34
-#[derive(Debug, Clone, Copy)]
34
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3535
 pub enum ResizeEdge {
3636
     TopLeft,
37
+    Top,
3738
     TopRight,
39
+    Left,
40
+    Right,
3841
     BottomLeft,
42
+    Bottom,
3943
     BottomRight,
44
+    None,
4045
 }
4146
 
4247
 impl WindowManager {
@@ -124,22 +129,26 @@ impl WindowManager {
124129
                 continue;
125130
             }
126131
 
132
+            // Check window rules and EWMH hints
133
+            let rule_actions = self.check_rules(window);
134
+            let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
135
+
127136
             // Subscribe to events on the window
128
-            self.conn.select_input(
129
-                window,
130
-                EventMask::ENTER_WINDOW
131
-                    | EventMask::FOCUS_CHANGE
132
-                    | EventMask::PROPERTY_CHANGE
133
-                    | EventMask::STRUCTURE_NOTIFY,
134
-            )?;
137
+            // Floating windows get POINTER_MOTION for edge resize cursors
138
+            let base_events = EventMask::ENTER_WINDOW
139
+                | EventMask::FOCUS_CHANGE
140
+                | EventMask::PROPERTY_CHANGE
141
+                | EventMask::STRUCTURE_NOTIFY;
142
+            let events = if should_float {
143
+                base_events | EventMask::POINTER_MOTION
144
+            } else {
145
+                base_events
146
+            };
147
+            self.conn.select_input(window, events)?;
135148
 
136149
             // Grab button for click-to-focus
137150
             self.conn.grab_button(window)?;
138151
 
139
-            // Check window rules and EWMH hints
140
-            let rule_actions = self.check_rules(window);
141
-            let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
142
-
143152
             // Manage the window
144153
             if should_float {
145154
                 self.manage_window_floating(window);
@@ -154,7 +163,10 @@ impl WindowManager {
154163
             // Focus the first window
155164
             if let Some(window) = self.focused_window {
156165
                 self.set_focus(window, true)?;
157
-                self.conn.ungrab_button(window)?;
166
+                // Ungrab button for click-through (unless floating - keep for edge resize)
167
+                if !self.is_floating(window) {
168
+                    self.conn.ungrab_button(window)?;
169
+                }
158170
             }
159171
             tracing::info!("Adopted {} existing windows", adopted);
160172
         }
@@ -233,18 +245,6 @@ impl WindowManager {
233245
             return Ok(());
234246
         }
235247
 
236
-        // Subscribe to events on the window
237
-        self.conn.select_input(
238
-            window,
239
-            EventMask::ENTER_WINDOW
240
-                | EventMask::FOCUS_CHANGE
241
-                | EventMask::PROPERTY_CHANGE
242
-                | EventMask::STRUCTURE_NOTIFY,
243
-        )?;
244
-
245
-        // Grab button for click-to-focus
246
-        self.conn.grab_button(window)?;
247
-
248248
         // Check window rules first
249249
         let rule_actions = self.check_rules(window);
250250
 
@@ -255,6 +255,22 @@ impl WindowManager {
255255
         // Determine if window should float (rule > ICCCM/EWMH hints)
256256
         let should_float = rule_actions.floating.unwrap_or_else(|| self.conn.should_float(window));
257257
 
258
+        // Subscribe to events on the window
259
+        // Floating windows get POINTER_MOTION for edge resize cursors
260
+        let base_events = EventMask::ENTER_WINDOW
261
+            | EventMask::FOCUS_CHANGE
262
+            | EventMask::PROPERTY_CHANGE
263
+            | EventMask::STRUCTURE_NOTIFY;
264
+        let events = if should_float {
265
+            base_events | EventMask::POINTER_MOTION
266
+        } else {
267
+            base_events
268
+        };
269
+        self.conn.select_input(window, events)?;
270
+
271
+        // Grab button for click-to-focus
272
+        self.conn.grab_button(window)?;
273
+
258274
         // Manage window on target workspace
259275
         if target_idx != self.focused_workspace {
260276
             // Window goes to a different workspace
@@ -403,8 +419,16 @@ impl WindowManager {
403419
         let child = event.child;
404420
         tracing::debug!("ButtonPress on window {}, child {}, button {}", window, child, event.detail);
405421
 
422
+        // If we're already in a drag, ignore additional button presses
423
+        if self.drag_state.is_some() {
424
+            tracing::debug!("Already in drag state, ignoring ButtonPress");
425
+            return Ok(());
426
+        }
427
+
406428
         // Check for mod+click on floating windows (move/resize)
407
-        let has_mod = event.state.contains(x11rb::protocol::xproto::KeyButMask::MOD1);
429
+        // Support both Alt (MOD1) and Super (MOD4) as the modifier
430
+        let has_mod = event.state.contains(x11rb::protocol::xproto::KeyButMask::MOD1)
431
+            || event.state.contains(x11rb::protocol::xproto::KeyButMask::MOD4);
408432
 
409433
         // For Alt+click from root grab, use child window (the window under cursor)
410434
         let target = if window == self.conn.root && child != 0 {
@@ -429,8 +453,8 @@ impl WindowManager {
429453
                 self.conn.grab_pointer(target)?;
430454
                 return Ok(());
431455
             } else if event.detail == 3 {
432
-                // Mod+Button3 = Resize
433
-                let edge = determine_resize_edge(&geometry, event.root_x, event.root_y);
456
+                // Mod+Button3 = Resize (quadrant-based edge detection)
457
+                let edge = determine_resize_edge_quadrant(&geometry, event.root_x, event.root_y);
434458
                 tracing::debug!("Starting resize for floating window {}, edge {:?}", target, edge);
435459
                 self.drag_state = Some(DragState::Resize {
436460
                     window: target,
@@ -444,6 +468,45 @@ impl WindowManager {
444468
             }
445469
         }
446470
 
471
+        // Check for edge resize on floating windows (click on edge without mod key)
472
+        let is_floating_win = self.is_floating(window);
473
+        tracing::debug!(
474
+            "Edge resize check: window={}, is_floating={}, button={}, pos=({},{})",
475
+            window, is_floating_win, event.detail, event.root_x, event.root_y
476
+        );
477
+
478
+        if !has_mod && is_floating_win && event.detail == 1 {
479
+            let geometry = self.get_floating_geometry(window);
480
+            let edge = determine_resize_edge(&geometry, event.root_x, event.root_y);
481
+            tracing::debug!(
482
+                "Edge detection: geometry=({},{} {}x{}), edge={:?}",
483
+                geometry.x, geometry.y, geometry.width, geometry.height, edge
484
+            );
485
+            if edge != ResizeEdge::None {
486
+                tracing::info!("Starting edge resize for floating window {}, edge {:?}", window, edge);
487
+                self.drag_state = Some(DragState::Resize {
488
+                    window,
489
+                    start_x: event.root_x,
490
+                    start_y: event.root_y,
491
+                    start_geometry: geometry.clone(),
492
+                    edge,
493
+                });
494
+                tracing::debug!("Grabbing pointer for resize, geometry={:?}", geometry);
495
+                self.conn.grab_pointer(window)?;
496
+                self.conn.flush()?;
497
+                return Ok(());
498
+            }
499
+            // Not on edge - if this is a focused floating window, replay the click
500
+            if self.focused_window == Some(window) {
501
+                self.conn.conn.allow_events(
502
+                    x11rb::protocol::xproto::Allow::REPLAY_POINTER,
503
+                    x11rb::CURRENT_TIME,
504
+                )?;
505
+                self.conn.flush()?;
506
+                return Ok(());
507
+            }
508
+        }
509
+
447510
         // Only handle if we manage this window
448511
         if !self.windows.contains_key(&window) {
449512
             return Ok(());
@@ -451,14 +514,21 @@ impl WindowManager {
451514
 
452515
         // Focus the clicked window
453516
         if self.focused_window != Some(window) {
454
-            // Regrab button on old focused window
517
+            // Regrab button on old focused window (unless it's floating - keep grab for edge resize)
455518
             if let Some(old) = self.focused_window {
456
-                self.conn.grab_button(old)?;
519
+                if !self.is_floating(old) {
520
+                    self.conn.grab_button(old)?;
521
+                }
457522
             }
458523
 
459
-            // Set focus and ungrab button on new focused window (no warp - mouse click)
524
+            // Set focus
460525
             self.set_focus(window, false)?;
461
-            self.conn.ungrab_button(window)?;
526
+
527
+            // For non-floating windows, ungrab button for click-through
528
+            // For floating windows, keep grab for edge resize detection
529
+            if !self.is_floating(window) {
530
+                self.conn.ungrab_button(window)?;
531
+            }
462532
 
463533
             // Raise floating windows on focus
464534
             if self.is_floating(window) {
@@ -476,7 +546,9 @@ impl WindowManager {
476546
         Ok(())
477547
     }
478548
 
479
-    fn handle_button_release(&mut self, _event: ButtonReleaseEvent) -> Result<()> {
549
+    fn handle_button_release(&mut self, event: ButtonReleaseEvent) -> Result<()> {
550
+        tracing::debug!("ButtonRelease: button={}, window={}, in_drag={}",
551
+            event.detail, event.event, self.drag_state.is_some());
480552
         if self.drag_state.is_some() {
481553
             tracing::debug!("Ending drag operation");
482554
             self.drag_state = None;
@@ -487,10 +559,24 @@ impl WindowManager {
487559
     }
488560
 
489561
     fn handle_motion_notify(&mut self, event: MotionNotifyEvent) -> Result<()> {
490
-        let Some(ref drag) = self.drag_state else {
562
+        // If not in a drag, check for edge cursor changes on floating windows
563
+        if self.drag_state.is_none() {
564
+            let window = event.event;
565
+            let is_managed = self.windows.contains_key(&window);
566
+            let is_float = is_managed && self.is_floating(window);
567
+
568
+            if is_float {
569
+                tracing::trace!(
570
+                    "Motion on floating window {}: root({},{}) event({},{})",
571
+                    window, event.root_x, event.root_y, event.event_x, event.event_y
572
+                );
573
+                self.update_edge_cursor(window, event.root_x, event.root_y)?;
574
+            }
491575
             return Ok(());
492
-        };
576
+        }
493577
 
578
+        let drag = self.drag_state.as_ref().unwrap();
579
+        tracing::debug!("Motion during drag: root({},{}), drag_state={:?}", event.root_x, event.root_y, drag);
494580
         match drag {
495581
             DragState::Move {
496582
                 window,
@@ -531,6 +617,53 @@ impl WindowManager {
531617
         Ok(())
532618
     }
533619
 
620
+    /// Handle pointer motion for edge cursor changes on floating windows.
621
+    fn update_edge_cursor(&mut self, window: u32, root_x: i16, root_y: i16) -> Result<()> {
622
+        if !self.is_floating(window) {
623
+            // Not a floating window, ensure cursor is normal
624
+            if self.current_edge_cursor.is_some() {
625
+                self.conn.set_window_cursor(window, self.conn.cursor_normal)?;
626
+                self.current_edge_cursor = None;
627
+            }
628
+            return Ok(());
629
+        }
630
+
631
+        let geometry = self.get_floating_geometry(window);
632
+        let edge = determine_resize_edge(&geometry, root_x, root_y);
633
+
634
+        // Check if we need to update the cursor
635
+        let current = self.current_edge_cursor;
636
+        if current.map(|(w, e)| (w, e)) == Some((window, edge)) {
637
+            return Ok(()); // No change needed
638
+        }
639
+
640
+        // Get the appropriate cursor for this edge
641
+        let cursor = match edge {
642
+            ResizeEdge::TopLeft => self.conn.cursor_top_left,
643
+            ResizeEdge::Top => self.conn.cursor_top,
644
+            ResizeEdge::TopRight => self.conn.cursor_top_right,
645
+            ResizeEdge::Left => self.conn.cursor_left,
646
+            ResizeEdge::Right => self.conn.cursor_right,
647
+            ResizeEdge::BottomLeft => self.conn.cursor_bottom_left,
648
+            ResizeEdge::Bottom => self.conn.cursor_bottom,
649
+            ResizeEdge::BottomRight => self.conn.cursor_bottom_right,
650
+            ResizeEdge::None => self.conn.cursor_normal,
651
+        };
652
+
653
+        // Set cursor on the window
654
+        self.conn.set_window_cursor(window, cursor)?;
655
+        self.conn.flush()?;
656
+
657
+        if edge != ResizeEdge::None {
658
+            tracing::debug!("Set resize cursor {:?} for floating window {} edge", edge, window);
659
+            self.current_edge_cursor = Some((window, edge));
660
+        } else {
661
+            self.current_edge_cursor = None;
662
+        }
663
+
664
+        Ok(())
665
+    }
666
+
534667
     fn handle_enter_notify(&mut self, event: EnterNotifyEvent) -> Result<()> {
535668
         let window = event.event;
536669
 
@@ -563,14 +696,20 @@ impl WindowManager {
563696
 
564697
         tracing::debug!("Focus follows mouse: focusing window {}", window);
565698
 
566
-        // Regrab button on old focused window
699
+        // Regrab button on old focused window (unless floating - keep for edge resize)
567700
         if let Some(old) = self.focused_window {
568
-            self.conn.grab_button(old)?;
701
+            if !self.is_floating(old) {
702
+                self.conn.grab_button(old)?;
703
+            }
569704
         }
570705
 
571706
         // Focus the new window (no warp - mouse enter)
572707
         self.set_focus(window, false)?;
573
-        self.conn.ungrab_button(window)?;
708
+
709
+        // Ungrab button for click-through (unless floating - keep for edge resize)
710
+        if !self.is_floating(window) {
711
+            self.conn.ungrab_button(window)?;
712
+        }
574713
 
575714
         // Raise floating windows on focus
576715
         if self.is_floating(window) {
@@ -968,11 +1107,17 @@ impl WindowManager {
9681107
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
9691108
             .or_else(|| self.workspaces[workspace_idx].tree.first_window())
9701109
         {
1110
+            // Regrab on old window (unless floating)
9711111
             if let Some(old) = self.focused_window {
972
-                self.conn.grab_button(old)?;
1112
+                if !self.is_floating(old) {
1113
+                    self.conn.grab_button(old)?;
1114
+                }
9731115
             }
9741116
             self.set_focus(window, true)?;
975
-            self.conn.ungrab_button(window)?;
1117
+            // Ungrab on new window (unless floating - keep for edge resize)
1118
+            if !self.is_floating(window) {
1119
+                self.conn.ungrab_button(window)?;
1120
+            }
9761121
         } else {
9771122
             // No windows on target monitor - clear focus and warp to monitor center
9781123
             self.focused_window = None;
@@ -1646,11 +1791,17 @@ impl WindowManager {
16461791
             .or_else(|| self.workspaces[workspace_idx].floating.last().copied())
16471792
             .or_else(|| self.workspaces[workspace_idx].tree.first_window())
16481793
         {
1794
+            // Regrab on old window (unless floating)
16491795
             if let Some(old) = self.focused_window {
1650
-                self.conn.grab_button(old)?;
1796
+                if !self.is_floating(old) {
1797
+                    self.conn.grab_button(old)?;
1798
+                }
16511799
             }
16521800
             self.set_focus(window, true)?;
1653
-            self.conn.ungrab_button(window)?;
1801
+            // Ungrab on new window (unless floating - keep for edge resize)
1802
+            if !self.is_floating(window) {
1803
+                self.conn.ungrab_button(window)?;
1804
+            }
16541805
         } else {
16551806
             // No windows - warp to monitor center
16561807
             self.focused_window = None;
@@ -1761,14 +1912,16 @@ impl WindowManager {
17611912
 
17621913
         let next_window = floating[next_idx];
17631914
 
1764
-        // Regrab button on old focused window
1915
+        // Regrab button on old focused window (unless it's floating)
17651916
         if let Some(old) = self.focused_window {
1766
-            self.conn.grab_button(old)?;
1917
+            if !self.is_floating(old) {
1918
+                self.conn.grab_button(old)?;
1919
+            }
17671920
         }
17681921
 
17691922
         // Focus and raise the next floating window (keyboard action, warp pointer)
1923
+        // Keep button grabbed on floating windows for edge resize
17701924
         self.set_focus(next_window, true)?;
1771
-        self.conn.ungrab_button(next_window)?;
17721925
         self.raise_window(next_window)?;
17731926
 
17741927
         tracing::debug!("Cycled to floating window {} (idx {})", next_window, next_idx);
@@ -1800,6 +1953,21 @@ impl WindowManager {
18001953
                 win.floating = false;
18011954
             }
18021955
 
1956
+            // Remove POINTER_MOTION event mask (no longer need edge detection)
1957
+            self.conn.select_input(
1958
+                window,
1959
+                EventMask::ENTER_WINDOW
1960
+                    | EventMask::FOCUS_CHANGE
1961
+                    | EventMask::PROPERTY_CHANGE
1962
+                    | EventMask::STRUCTURE_NOTIFY,
1963
+            )?;
1964
+
1965
+            // Clear edge cursor state if this window had one
1966
+            if self.current_edge_cursor.map(|(w, _)| w) == Some(window) {
1967
+                self.conn.set_window_cursor(window, self.conn.cursor_normal)?;
1968
+                self.current_edge_cursor = None;
1969
+            }
1970
+
18031971
             // Remove from floating list
18041972
             self.current_workspace_mut().remove_floating(window);
18051973
 
@@ -1842,6 +2010,20 @@ impl WindowManager {
18422010
                 win.floating_geometry = geometry;
18432011
             }
18442012
 
2013
+            // Add POINTER_MOTION event mask for edge detection
2014
+            self.conn.select_input(
2015
+                window,
2016
+                EventMask::ENTER_WINDOW
2017
+                    | EventMask::FOCUS_CHANGE
2018
+                    | EventMask::PROPERTY_CHANGE
2019
+                    | EventMask::STRUCTURE_NOTIFY
2020
+                    | EventMask::POINTER_MOTION,
2021
+            )?;
2022
+
2023
+            // Re-establish button grabs for edge resize detection
2024
+            // (buttons may have been ungrabbed when the window was focused as tiled)
2025
+            self.conn.grab_button(window)?;
2026
+
18452027
             // Add to floating list (on top)
18462028
             self.current_workspace_mut().add_floating(window);
18472029
 
@@ -2053,7 +2235,37 @@ fn parse_direction(s: &str) -> Option<Direction> {
20532235
     }
20542236
 }
20552237
 
2238
+/// Threshold in pixels for detecting edge proximity
2239
+const EDGE_THRESHOLD: i16 = 12;
2240
+
2241
+/// Determine which edge/corner of a window a point is near.
2242
+/// Returns ResizeEdge::None if not near any edge.
20562243
 fn determine_resize_edge(geometry: &Rect, click_x: i16, click_y: i16) -> ResizeEdge {
2244
+    let left = geometry.x;
2245
+    let right = geometry.x + geometry.width as i16;
2246
+    let top = geometry.y;
2247
+    let bottom = geometry.y + geometry.height as i16;
2248
+
2249
+    let near_left = click_x >= left && click_x < left + EDGE_THRESHOLD;
2250
+    let near_right = click_x > right - EDGE_THRESHOLD && click_x <= right;
2251
+    let near_top = click_y >= top && click_y < top + EDGE_THRESHOLD;
2252
+    let near_bottom = click_y > bottom - EDGE_THRESHOLD && click_y <= bottom;
2253
+
2254
+    match (near_left, near_right, near_top, near_bottom) {
2255
+        (true, _, true, _) => ResizeEdge::TopLeft,
2256
+        (_, true, true, _) => ResizeEdge::TopRight,
2257
+        (true, _, _, true) => ResizeEdge::BottomLeft,
2258
+        (_, true, _, true) => ResizeEdge::BottomRight,
2259
+        (true, _, _, _) => ResizeEdge::Left,
2260
+        (_, true, _, _) => ResizeEdge::Right,
2261
+        (_, _, true, _) => ResizeEdge::Top,
2262
+        (_, _, _, true) => ResizeEdge::Bottom,
2263
+        _ => ResizeEdge::None,
2264
+    }
2265
+}
2266
+
2267
+/// Determine resize edge for mod+click (quadrant-based, always picks a corner)
2268
+fn determine_resize_edge_quadrant(geometry: &Rect, click_x: i16, click_y: i16) -> ResizeEdge {
20572269
     let center_x = geometry.x + geometry.width as i16 / 2;
20582270
     let center_y = geometry.y + geometry.height as i16 / 2;
20592271
 
@@ -2082,20 +2294,37 @@ fn calculate_resize(geometry: &Rect, edge: ResizeEdge, dx: i16, dy: i16) -> (i16
20822294
             w = (w as i16 - dx).max(MIN_SIZE as i16) as u16;
20832295
             h = (h as i16 - dy).max(MIN_SIZE as i16) as u16;
20842296
         }
2297
+        ResizeEdge::Top => {
2298
+            y += dy;
2299
+            h = (h as i16 - dy).max(MIN_SIZE as i16) as u16;
2300
+        }
20852301
         ResizeEdge::TopRight => {
20862302
             y += dy;
20872303
             w = (w as i16 + dx).max(MIN_SIZE as i16) as u16;
20882304
             h = (h as i16 - dy).max(MIN_SIZE as i16) as u16;
20892305
         }
2306
+        ResizeEdge::Left => {
2307
+            x += dx;
2308
+            w = (w as i16 - dx).max(MIN_SIZE as i16) as u16;
2309
+        }
2310
+        ResizeEdge::Right => {
2311
+            w = (w as i16 + dx).max(MIN_SIZE as i16) as u16;
2312
+        }
20902313
         ResizeEdge::BottomLeft => {
20912314
             x += dx;
20922315
             w = (w as i16 - dx).max(MIN_SIZE as i16) as u16;
20932316
             h = (h as i16 + dy).max(MIN_SIZE as i16) as u16;
20942317
         }
2318
+        ResizeEdge::Bottom => {
2319
+            h = (h as i16 + dy).max(MIN_SIZE as i16) as u16;
2320
+        }
20952321
         ResizeEdge::BottomRight => {
20962322
             w = (w as i16 + dx).max(MIN_SIZE as i16) as u16;
20972323
             h = (h as i16 + dy).max(MIN_SIZE as i16) as u16;
20982324
         }
2325
+        ResizeEdge::None => {
2326
+            // No resize
2327
+        }
20992328
     }
21002329
 
21012330
     (x, y, w, h)