Skip to content

Snap/throw mispositions and won't move windows that can't resize to a half (fixed/min-max/size-increment apps) #37

Description

@pignuante

Summary

Snap and snap-throw don't position correctly — and snap-throw won't move to the next display — for windows that can't resize to the requested half. Most apps are freely resizable and work fine; this affects apps whose window size is constrained.

This is a general limitation of how target frames are computed against the intended size while the macOS Accessibility API only requests a size. An attempt to fix it was made and reverted (details below) because the fix introduced a visible flicker; the limitation is being filed here so it can be solved cleanly later.

Affected windows

Any window the app won't resize to the half we ask for:

  • Fixed-size / fixed-width windows (utility panels, some dialogs)
  • Windows with a minimum or maximum content size that the half violates
  • Size-increment windows (terminals snap to character cells, so they stop a cell short)

Freely resizable windows (Safari, most apps) are not affected — they fill the half, so placement and throw work normally.

Symptoms

Take a fixed-width window and ⌃⌥→ (snap right):

  1. Not flush to the screen edge. The window lands at the half's inner edge (screen center) with a gap to the right screen edge, instead of sitting flush against the right edge. (Same for left/top/bottom and center placements — anything anchored to an edge/center other than the work-area origin.)
  2. Throw never fires. Pressing ⌃⌥→ again should throw the window to the adjacent display; instead it just re-snaps in place. The "already snapped → throw" check requires the window to cover ~90% of the half, which a constrained window can never reach.

Root cause

  • AX kAXSizeAttribute is a request, not a guarantee. Apps may clamp it, partially honor it, or apply it asynchronously. WindowFrameWriter applies position → size → position once and reads the result back.
  • Target frames derive position from the intended size. FrameCalculator computes, e.g., the right-half origin as workArea.midX assuming the window becomes halfWidth wide. If the achieved width differs, the right/center/bottom-anchored origin is wrong (the window is placed as if it were the intended size).
  • fillsHalf (the throw trigger) is coverage-based (coverage >= 0.9). A window that can't fill the half never passes, so snap-throw degrades to a plain re-snap.

Reproduction

  1. Use an app whose window won't resize to a screen half (a fixed-width utility window, or a terminal with cell snapping).
  2. ⌃⌥→ → window sits offset from the right edge, not flush.
  3. ⌃⌥→ again → it does not throw to the adjacent monitor; it stays put.

Attempted fix (reverted)

An experimental fix was implemented and then rolled back (preserved locally on a backup branch; the feature branch was reset to before it):

  • Re-anchor by achieved size — after applying, read back the real size and recompute the position so the window is flush to the intended edge / centered.
  • Stateful snap-throw — remember each window's last snap edge so a repeat press throws regardless of coverage.
  • Diagonal / L-shaped monitor adjacency — allow moving to a monitor that is diagonally placed (no perpendicular overlap).

It was reverted because:

  • Two-stage flicker for every app. Re-anchoring needs a second AX write (apply target → apply corrected position), so the window visibly jumps to a wrong spot (sometimes briefly onto the neighboring monitor) and then settles. The motion is no longer smooth — a regression that affects normal apps too, not just constrained ones.
  • An internal config window used while testing was extra-pathological (its content width oscillated by ~17px due to always-shown scrollbars), which also made the stateful match flaky — but the flicker alone justified the revert.

Why a clean fix is hard

  • You can't know the achieved size before applying, so any edge/center anchoring needs a correction after — i.e., a second write → flicker.
  • AX resizes can be asynchronous/animated (notably when AXEnhancedUserInterface is enabled for assistive clients while a window manager is active), so a single set doesn't land exactly and waiting for it to settle conflicts with responsiveness.

Possible approaches

  1. Temporarily disable AXEnhancedUserInterface around the resize (read → set false → resize → restore). This makes the resize synchronous, immediate, and exact — the approach Rectangle and yabai use, which would let us anchor correctly in a single pass without flicker.
    • Trade-offs: it's a semi-private attribute, can affect assistive technologies, and is macOS-version-sensitive. Behavior on macOS 26 (Tahoe) is unverified — must verify against current docs before shipping, and gate it behind an opt-in setting.
  2. Coverage-independent throw trigger (e.g., remember the last snap, or treat "flush against the outer edge of the half" as snapped) so constrained windows can throw — without the re-anchor flicker.
  3. Single combined apply that anchors using a best estimate of the final size, accepting minor imprecision instead of a second write.

Acceptance criteria

  • A fixed-size window snapped to an edge sits flush against the screen edge, not the dividing line.
  • Snap-throw moves such a window to the adjacent display on repeat, regardless of how much of the half it covers.
  • No visible multi-stage flicker for normal resizable apps.

Notes

  • Azimuth's own Settings/About windows are intentionally not snap targets (standard for window managers); that is separate from this issue.
  • Related: the existing "AX resize isn't pixel-exact for some apps (e.g. Safari overshoots a half by ~64px)" observation. The snap-throw-not-firing part of that was already mitigated by making fillsHalf check only the inner dividing line; this issue is the broader constrained-size class.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workinghelp wantedExtra attention is needed

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions