Skip to content

Add command right click functionality for window resizing like in i3wm#1618

Open
tymscar wants to merge 8 commits intonikitabobko:mainfrom
tymscar:add-right-click-resize
Open

Add command right click functionality for window resizing like in i3wm#1618
tymscar wants to merge 8 commits intonikitabobko:mainfrom
tymscar:add-right-click-resize

Conversation

@tymscar
Copy link

@tymscar tymscar commented Aug 7, 2025

screen-recording-pr

This PR adds the right click drag feature from i3wm.
The user should be able to command right-click and drag on any window, and it will resize in the correct direction. If you drag to the left from the left side, you make it bigger, for example.

I have gotten it quite smooth, and as far as I can tell, it actually circumvents the issue that arises when you manually resize a window over another one, and they switch spaces. I can't replicate that with this method. This method runs at the refresh rate of your fastest monitor, or if that fails, I default it to 60fps.

I have also made it so when you hold Command, it doesn't pass through the right-click to the window under it. That was annoying because while dragging, I would by mistake click on Back or something.

Fixes issue #206

@tymscar
Copy link
Author

tymscar commented Sep 26, 2025

Hello!

Sorry for bothering @nikitabobko, I just wanted to know if theres anything else needed from me for this PR?

Copy link
Owner

@nikitabobko nikitabobko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. I do want to implement this feature at some point.

I was thinking of more generic mouse events where users could assign command for whatever mouse + keyboard events (as described here: #371), but we can start small and implement the mouse dragging/moving with the modifier the way you propose it in this PR, and the way it's done in i3

I have left my first iteration review comments. The most major problem is logic duplication. Please share the logic with existing func resizeWithMouse. Once/If you fix the comments, we can discuss the PR further

(1 << CGEventType.mouseMoved.rawValue) |
(1 << CGEventType.flagsChanged.rawValue),
)
if cmdRightTap == nil,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GlobalObserver.initObserver is called only once at startup. Why do you need cmdRightTap == nil check?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about that. I usually try to code more defensively. I didn't look to deep into how we reload the config, so I was thinking the whole thing might get reinitialised.

Comment on lines +104 to +120
@MainActor
private func scheduleThrottledRefresh() {
if pendingDragRefreshTask != nil { return }
pendingDragRefreshTask = Task { @MainActor in
try? await Task.sleep(for: preferredFrameDuration())
runRefreshSession(.globalObserver("cmdRightMouseDragged"), optimisticallyPreLayoutWorkspaces: true)
pendingDragRefreshTask = nil
}
}

@MainActor
private func preferredFrameDuration() -> Duration {
let maxFps = NSScreen.screens.map { $0.maximumFramesPerSecond }.max() ?? 60
let fps = max(maxFps, 1)
let nanosPerFrame = 1_000_000_000 / fps
return .nanoseconds(nanosPerFrame)
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please take a look at how throttling is implemented in func movedObs and do the same.

I think there is no need for Task.sleep, and there is no need for complicated frames math

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh yeah, that's so much cleaner!

Comment on lines +69 to +92
func onCmdRightMouseDragged() async {
guard let session = cmdRightResizeSession else { return }
guard let window = Window.get(byId: session.windowId) else { return }

let point = mouseLocation
let direction = edgeToDirection(session.edge)
let delta: CGFloat = (direction.orientation == .h) ? (point.x - session.startPoint.x) : (point.y - session.startPoint.y)
let diff: CGFloat = direction.isPositive ? delta : -delta

guard let (parent, _, neighborIndex, orientation) = resolveParentAndNeighbor(window, direction) else { return }
if abs(diff) < 1 { return }

window.parentsWithSelf.lazy
.prefix(while: { $0 !== parent })
.compactMap { node -> TreeNode? in
let p = node.parent as? TilingContainer
return (p?.orientation == orientation && p?.layout == .tiles) ? node : nil
}
.forEach { $0.setWeight(orientation, $0.getWeightBeforeResize(orientation) + diff) }

let sibling = parent.children[neighborIndex]
sibling.setWeight(orientation, sibling.getWeightBeforeResize(orientation) - diff)

currentlyManipulatedWithMouseWindowId = window.windowId
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read this code diagonally, so maybe I'm missing something, but my intuition says that the existing func resizeWithMouse can be reused

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You might need to refactor func resizeWithMouse to make it more generic to handle both cases, but I don't want these obviously related logics being implemented two times in two different ways

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your custom logic doesn't handle more complicated cases:

Screen.Recording.2025-10-02.at.13.47.28.mov

callback: { _, type, event, _ in
if !TrayMenuModel.shared.isEnabled { return Unmanaged.passUnretained(event) }
let flags = event.flags
let isCmd = flags.contains(.maskCommand)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The modifier must be configurable in the config

Smth like:

mouse-resize-modifier = 'alt' # Possible values: cmd, alt, ctrl, shift

AeroSpace's default MOD key is alt, so that should be the default for resizing as well

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense. I personally always prefer command for this sort of things. Thanks for bringing this up!

import Common


private enum ResizeEdge { case left, right, up, down }
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't CardinalDirection just be reused?

@tymscar
Copy link
Author

tymscar commented Oct 3, 2025

Thanks so much for the review @nikitabobko !

So I spent quite a while trying all sorts of things out and I think I got something that reduces the duplication as much as I can.

You might know this better, but as far as I can tell, I think the way we resize with the mouse normally and how right-click drag should work is totally different.

In the case of the mouse resize, that's just normal macOS resizing, and then the accessibility framework updates us on what happened so we can sync up the weights. Whereas for the right-click drag one, that's fully custom, which means we have to handle the actual resizing logic. So what I ended up doing was I factored out the weights calculation and cleaned up the logic a bit, and now both methods use that, but I am not married to the idea. I am all ears if you think there's something cleaner.

Thanks for giving me the opportunity to learn more!

@nikitabobko nikitabobko force-pushed the main branch 2 times, most recently from f324d2e to 7aff813 Compare November 24, 2025 01:31
@tymscar tymscar force-pushed the add-right-click-resize branch from f9cb42a to 8f31af7 Compare November 30, 2025 17:12
@tymscar tymscar force-pushed the add-right-click-resize branch from 33d72d5 to 98358ff Compare November 30, 2025 17:14
@tymscar
Copy link
Author

tymscar commented Nov 30, 2025

Hey @nikitabobko!

I have been using this fork now for four months daily, and I find it super helpful. So I spent some time fixing merge conflicts, rebased onto main, as well as fixed the issue with resizing diagonals you have told me about using that GIF.

@tymscar tymscar force-pushed the add-right-click-resize branch from f0bfc64 to df97f49 Compare November 30, 2025 17:52
@tymscar tymscar requested a review from nikitabobko November 30, 2025 18:06
@jthomaschewski
Copy link

@tymscar Thanks a lot for the PR! I’ve been running your branch for a week with no major issues.

Just one thing: Would it be possible to add support for resizing floating windows as well?
Currently it appears to work only for windows within split containers

@tymscar
Copy link
Author

tymscar commented Dec 1, 2025

Thank you @jthomaschewski !

I don't really use floating windows, and I will be busy starting from tomorrow until middle of December, but I might take a look at it after that!

Davincible added a commit to Davincible/AeroSpace that referenced this pull request Feb 11, 2026
# Conflicts:
#	Sources/AppBundle/GlobalObserver.swift
#	Sources/AppBundle/config/Config.swift
#	Sources/AppBundle/config/parseConfig.swift
#	Sources/AppBundle/mouse/mouse.swift
Davincible added a commit to Davincible/AeroSpace that referenced this pull request Feb 11, 2026
Phase 4 tab detection fixes (5 blocking, 7 non-blocking):
- Fix index out-of-bounds crash on tab promotion (clamp saved index)
- Fix tryOnWindowDetected skipped for slot-restored tabs
- Fix suspendedWindowSlots cross-reference leak in tryRestoreTabSlot
- Fix getWindowLevel mid-cycle cache rebuild causing state inconsistency
- Fix floating-point group keys (round bounds to integers)
- Add O(1) reverse lookups for isBackgroundTab and tabGroupKey
- Replace force-cast with guard-let in validateStillPopups
- Make TabGroup struct private, add access-level documentation
- Add explanatory comments for layoutReason and false-positive risks

Phase 1-3 pre-existing bug fixes:
- Fix dead code in GlobalObserver flagsChanged handler (PR nikitabobko#1618)
  The guard-isModifier prevented modifier-release events from reaching
  the switch, so releasing the resize modifier mid-drag never terminated
  the resize session. Moved flagsChanged before the guard.
- Fix swapWindows using window1.ownIndex instead of window2.ownIndex
  in moveWithMouse.swift
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants