Skip to content

Fix on-window-detected floating rule causing brief tiling flash#2024

Open
willzeng274 wants to merge 1 commit intonikitabobko:mainfrom
willzeng274:fix/on-window-detected-tiling-flash
Open

Fix on-window-detected floating rule causing brief tiling flash#2024
willzeng274 wants to merge 1 commit intonikitabobko:mainfrom
willzeng274:fix/on-window-detected-tiling-flash

Conversation

@willzeng274
Copy link
Copy Markdown

@willzeng274 willzeng274 commented Mar 25, 2026

Fixes two related visual glitches when an on-window-detected rule applies layout floating:

  1. The new window briefly flashes at its tiled position before floating.
  2. Sibling tiling windows briefly shift to make room for the incoming window's tile slot.

See the commit message for root cause and fix details.

Related discussion: #2016

Summary:

  • if you set an app as layout floating in [on-window-detected], sometimes it tries to tile it first

  • I've read CONTRIBUTING.md
  • My PR contains atomic commits (each commit is a self-contained change with a descriptive message explaining what and why)
  • My PR doesn't contain merge commits (I've rebased on top of the target branch instead)
  • If my PR is ready for review, I've marked it as such (not a draft)
  • I've added a link to the relevant GitHub Discussion (if applicable)
  • I've tested my changes manually

@willzeng274 willzeng274 force-pushed the fix/on-window-detected-tiling-flash branch from 064f643 to 4e99854 Compare March 25, 2026 06:38
When exec-and-forget launches an app, multiple notifications fire
rapidly (kAXWindowCreatedNotification, didActivateApplicationNotification,
etc.), each calling scheduleRefreshSession(). Due to Swift cooperative
cancellation, a session already running past checkCancellation() continues
executing even after being "cancelled" by a newer session. Two sessions
end up interleaved on @mainactor at every await suspension point.

Race 1 (tiling flash): Session A places the new window in the tiling
container (synchronous), then suspends inside tryOnWindowDetected() while
evaluating rules via async AX calls. Session B gets CPU, runs
layoutWorkspaces(), sees the window in the tiling container, and calls
setAxFrame() -- producing a visible flash at the tiled position.

Race 2 (sibling shift): layoutTiles() distributes tile space among all
children by weight. While the new window sits in the tiling container
awaiting rule evaluation, every concurrent layoutWorkspaces() call shrinks
sibling windows to reserve a tile slot for it -- a slot that is never
visually filled -- causing siblings to briefly shift.

Fix: add isAwaitingOnWindowDetected flag to Window, set when the window
enters allWindowsMap, cleared via defer after tryOnWindowDetected completes
(covers both the normal path and the closedWindowsCache restore path).
layoutWorkspaces() skips flagged windows entirely so no concurrent session
can setAxFrame() the window while its rules are still being evaluated.
layoutTiles() also excludes flagged windows from space allocation so
siblings keep their full tile width during detection.
Session A's own layoutWorkspaces() runs after the flag is cleared and
positions the window correctly in its final container the first time.

nikitabobko#2016
@willzeng274 willzeng274 force-pushed the fix/on-window-detected-tiling-flash branch from 4e99854 to b41287a Compare March 25, 2026 09:48
@nikitabobko
Copy link
Copy Markdown
Owner

nikitabobko commented Apr 1, 2026

I didn't manage to reproduce the bug. But I believe that it could be theoretically happening.

I am not sure that I like the idea of introducing an additional boolean flag. This flickering is not the only problem that is caused by sessions interleaving (ref: #1311)

Question: does the following patch fix the issue?

Details
diff --git a/Sources/AppBundle/layout/layoutRecursive.swift b/Sources/AppBundle/layout/layoutRecursive.swift
index bc005b9b..f718a735 100644
--- a/Sources/AppBundle/layout/layoutRecursive.swift
+++ b/Sources/AppBundle/layout/layoutRecursive.swift
@@ -4,6 +4,7 @@ extension Workspace {
     @MainActor
     func layoutWorkspace() async throws {
         if isEffectivelyEmpty { return }
+        try checkCancellation()
         let rect = workspaceMonitor.visibleRectPaddedByOuterGaps
         // If monitors are aligned vertically and the monitor below has smaller width, then macOS may not allow the
         // window on the upper monitor to take full width. rect.height - 1 resolves this problem
@@ -15,6 +16,7 @@ extension Workspace {
 extension TreeNode {
     @MainActor
     fileprivate func layoutRecursive(_ point: CGPoint, width: CGFloat, height: CGFloat, virtual: Rect, _ context: LayoutContext) async throws {
+        try checkCancellation()
         let physicalRect = Rect(topLeftX: point.x, topLeftY: point.y, width: width, height: height)
         switch nodeCases {
             case .workspace(let workspace):
@@ -68,6 +70,7 @@ private struct LayoutContext {
 extension Window {
     @MainActor
     fileprivate func layoutFloatingWindow(_ context: LayoutContext) async throws {
+        try checkCancellation()
         let workspace = context.workspace
         let windowRect = try await getAxRect() // Probably not idempotent
         let currentMonitor = windowRect?.center.monitorApproximation
@@ -95,6 +98,7 @@ extension Window {
 
     @MainActor
     fileprivate func layoutFullscreen(_ context: LayoutContext) {
+        try checkCancellation()
         let monitorRect = noOuterGapsInFullscreen
             ? context.workspace.workspaceMonitor.visibleRect
             : context.workspace.workspaceMonitor.visibleRectPaddedByOuterGaps
@@ -105,6 +109,7 @@ extension Window {
 extension TilingContainer {
     @MainActor
     fileprivate func layoutTiles(_ point: CGPoint, width: CGFloat, height: CGFloat, virtual: Rect, _ context: LayoutContext) async throws {
+        try checkCancellation()
         var point = point
         var virtualPoint = virtual.topLeftCorner
 
@@ -140,6 +145,7 @@ extension TilingContainer {
 
     @MainActor
     fileprivate func layoutAccordion(_ point: CGPoint, width: CGFloat, height: CGFloat, virtual: Rect, _ context: LayoutContext) async throws {
+        try checkCancellation()
         guard let mruIndex: Int = mostRecentChild?.ownIndex else { return }
         for (index, child) in children.enumerated() {
             let padding = CGFloat(config.accordionPadding)

@willzeng274
Copy link
Copy Markdown
Author

hmm... for some reason I can't reproduce the issue anymore either, not sure what caused it tbh, I will report if I encounter the bug again.

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.

2 participants