Four separate defects, each reproduced before being fixed. Accessibility was silently denied. AXIsProcessTrusted() returned false, so every AX read failed, WindowOverlay.init? failed all twelve retries, and attach gave up without a word — indistinguishable from the feature being broken. The cause was outside this file: an ad-hoc-signed app has its permission pinned to its exact cdhash, which changes on every build, so the rebuild-and-replace workflow silently revoked the grant each time while the app stayed listed and ticked in System Settings. Now it prompts rather than failing mutely, and keeps rechecking — macOS never tells an app it has just been granted Accessibility, and overlays were otherwise only reconsidered when a Claude window launched, quit or activated, so a grant made while running did nothing visible until the user happened to touch a Claude window. The tag walked off its window on every drag. windowDidMove re-derived and persisted the corner offset for the app's own programmatic moves too, on the assumption that recomputing an offset it had just positioned against was a no-op. It isn't: AppKit posts that notification synchronously from inside setFrame, and during a live drag the AX frame read there is already newer than the one reposition used, so each event banked the few points the window had moved in between. Observed saturating at 952, which parks the tag at the far edge of the window or off-screen. Placement was anchored to the top-right unconditionally, so any resize moving that corner dragged the tag along. It now anchors to whichever corner it was dropped nearest. Offsets are no longer written back clamped either: a window too small to honour one shows the tag pushed in as far as it fits but keeps the stored distance, so widening the window restores the chosen spot. Styling: thinner (13pt) and pill-shaped so it can sit close to an edge without covering window controls; rotated when against a side edge, reading bottom-to-top on the left and top-to-bottom on the right so the text leans into the window; text colour chosen per profile colour by WCAG relative luminance rather than fixed white, which was as low as ~1.1:1 on a yellow chip against 4.50:1 worst-case now; and text drawn into a one-line-tall rect centred on the chip's midline, fixing its high seating. Settings gains a choice between the name chip and a plain coloured dot, which is never rotated, having no reading direction. Verified live throughout: a 400pt offset squeezed to 260 on a narrowed window and returned to 400 on restore with the stored value untouched; a drag committed the expected corner and orientation; and contrast was measured across the hue wheel. Tag placement is also stored per profile rather than once for the whole app. Two windows side by side are the case these tags exist for, and wanting each one's tag somewhere different is the normal outcome — one window's sidebar is not another's. Previously a single shared offset made them look independent (only the dragged tag moved at once) while every other tag snapped to it on its next reposition. A tag is a floating panel, which puts it above every ordinary window on the system rather than merely above the window it labels — so it hovered over the browser, the editor, and everything else, even when its own window was buried or on another Space. There is no cross-process way to attach one window above another (addChildWindow is same-process only, and the private ordering call window managers use is a one-shot that goes stale on the next reorder), so each tag now checks whether the window it labels is genuinely visible beneath it. The window server returns its list front-to-back, so one pass answers it: anything overlapping the tag before we reach our own window is covering it, and never reaching that window means it isn't on screen at all. Tag position is also clamped into whatever part of the window is on screen. A window dragged half off the edge takes its anchored corner with it, and a tag that follows it out of view identifies nothing. The clamp is presentational only — the stored anchor is untouched, so the tag returns to it once the window is fully back. Occlusion is judged from the window server's listing, which it returns front-to-back. Our own window is identified by its owning process rather than by matching rectangles: AX reports a new position the instant a window moves while the listing still holds the previous one, so a geometric match fails almost continuously mid-drag — measured at 87 of 92 frames, during which the walk ran past our own window and mistook whatever else overlapped the tag for something covering it. Only ordinary windows count as occluders. Everything above that band is permanently in front and would veto the tag forever — including the invisible one-pixel markers some utilities park in a screen corner, which is what made a tag vanish at the bottom-left and nowhere else: it takes a window moved off two edges at once for the tag to clamp into that pixel.
605 lines
28 KiB
Swift
605 lines
28 KiB
Swift
// A small floating tag pinned to a Claude window's corner, showing which
|
|
// profile it belongs to — every profile launches the same app with the
|
|
// same icon and title, so there's otherwise no way to tell two open
|
|
// Claude windows apart at a glance. Kept in sync live via AXObserver
|
|
// notifications (move/resize/miniaturize/destroy) rather than polling —
|
|
// only a persistent process can hold a watcher like this at all, which is
|
|
// exactly the gap the old one-shot CLI script couldn't close.
|
|
import AppKit
|
|
import ApplicationServices
|
|
|
|
// How the tag is drawn. A dot is for when the colour alone already
|
|
// identifies the window and the name is just clutter; it's never rotated,
|
|
// having no reading direction to preserve.
|
|
enum WindowOverlayStyle: String {
|
|
case chip, dot
|
|
}
|
|
|
|
// The window corner a tag is anchored to. Anchoring to the *nearest*
|
|
// corner rather than always the top-right is what holds a tag still
|
|
// through a resize: one parked near the bottom-left tracks that corner,
|
|
// so dragging the top-right handle no longer drags the tag with it.
|
|
enum WindowOverlayCorner: String {
|
|
case topLeft, topRight, bottomLeft, bottomRight
|
|
|
|
var isTop: Bool { self == .topLeft || self == .topRight }
|
|
var isLeft: Bool { self == .topLeft || self == .bottomLeft }
|
|
|
|
// Quadrant test rather than four distance comparisons — same answer,
|
|
// and it stays well-defined for a tag sitting dead centre.
|
|
static func nearest(to point: NSPoint, in frame: NSRect) -> WindowOverlayCorner {
|
|
switch (point.y > frame.midY, point.x < frame.midX) {
|
|
case (true, true): return .topLeft
|
|
case (true, false): return .topRight
|
|
case (false, true): return .bottomLeft
|
|
case (false, false): return .bottomRight
|
|
}
|
|
}
|
|
}
|
|
|
|
// Where one profile's tag sits on its window, persisted so a drag survives
|
|
// a relaunch. Stored per profile rather than once for everything: two
|
|
// windows side by side are exactly the case these tags exist for, and
|
|
// wanting each one's tag somewhere different is the normal outcome — one
|
|
// window's sidebar is not another's.
|
|
//
|
|
// Offsets are stored as the user dropped them and are never written back
|
|
// clamped. A window too small to honour the full offset shows the tag
|
|
// pushed in as far as it fits (see `WindowOverlay.frame(window:screen:)`), but the
|
|
// stored distance is left intact, so widening the window again restores
|
|
// the chosen spot instead of leaving the tag stuck where a temporary
|
|
// resize squeezed it.
|
|
struct WindowOverlayPlacement {
|
|
// Top-right by default — top-left is where every window's close/
|
|
// minimize/zoom controls live, which the tag must never sit over.
|
|
var corner: WindowOverlayCorner = .topRight
|
|
var insetX: CGFloat = 8
|
|
var insetY: CGFloat = 6
|
|
var isVertical: Bool = false
|
|
|
|
private static func key(_ field: String, _ profile: String) -> String {
|
|
"WindowOverlay\(field).\(profile)"
|
|
}
|
|
|
|
// From the version that kept a single shared offset. Read as a seed for
|
|
// a profile with nothing stored yet, so an existing tag stays where it
|
|
// already is instead of jumping on the first launch after upgrading.
|
|
private static let legacyInsetXKey = "WindowOverlayRightInset"
|
|
private static let legacyInsetYKey = "WindowOverlayTopInset"
|
|
|
|
private static func number(_ defaults: UserDefaults, _ keys: String...) -> CGFloat? {
|
|
for key in keys {
|
|
if let value = defaults.object(forKey: key) as? Double { return CGFloat(value) }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
static func load(for profile: String) -> WindowOverlayPlacement {
|
|
let defaults = UserDefaults.standard
|
|
var placement = WindowOverlayPlacement()
|
|
if let raw = defaults.string(forKey: key("Corner", profile)),
|
|
let corner = WindowOverlayCorner(rawValue: raw) {
|
|
placement.corner = corner
|
|
}
|
|
if let x = number(defaults, key("InsetX", profile), legacyInsetXKey) { placement.insetX = x }
|
|
if let y = number(defaults, key("InsetY", profile), legacyInsetYKey) { placement.insetY = y }
|
|
placement.isVertical = defaults.bool(forKey: key("Vertical", profile))
|
|
return placement
|
|
}
|
|
|
|
func save(for profile: String) {
|
|
let defaults = UserDefaults.standard
|
|
defaults.set(corner.rawValue, forKey: Self.key("Corner", profile))
|
|
defaults.set(Double(insetX), forKey: Self.key("InsetX", profile))
|
|
defaults.set(Double(insetY), forKey: Self.key("InsetY", profile))
|
|
defaults.set(isVertical, forKey: Self.key("Vertical", profile))
|
|
}
|
|
}
|
|
|
|
// Chip or dot. Unlike placement this really is one setting for the whole
|
|
// app — it's a single control in Settings, not something chosen per window.
|
|
enum WindowOverlayPosition {
|
|
private static let styleKey = "WindowOverlayStyle"
|
|
|
|
static var style: WindowOverlayStyle {
|
|
get {
|
|
(UserDefaults.standard.string(forKey: styleKey)).flatMap(WindowOverlayStyle.init(rawValue:)) ?? .chip
|
|
}
|
|
set { UserDefaults.standard.set(newValue.rawValue, forKey: styleKey) }
|
|
}
|
|
}
|
|
|
|
// Draws the tag itself. Custom drawing rather than a laid-out NSTextField
|
|
// because both of the things this has to get right — centring the text on
|
|
// the chip's actual midline, and rotating the whole chip when it's parked
|
|
// against a side edge — are a transform and two draw calls here, versus
|
|
// fighting a label's intrinsic baseline placement and its unrotatable
|
|
// frame.
|
|
private final class TagView: NSView {
|
|
var text: String
|
|
var color: NSColor
|
|
var style: WindowOverlayStyle
|
|
var isVertical: Bool
|
|
// Which side edge a vertical chip is against, which decides the way it
|
|
// reads: bottom-to-top on the left, top-to-bottom on the right, so the
|
|
// text always leans into the window rather than away from it. Ignored
|
|
// when horizontal.
|
|
var isLeftSide: Bool
|
|
|
|
static let thickness: CGFloat = 13
|
|
static let dotDiameter: CGFloat = 11
|
|
private static let horizontalPadding: CGFloat = 7
|
|
private static let maxLength: CGFloat = 140
|
|
static let font = NSFont.systemFont(ofSize: 10, weight: .semibold)
|
|
|
|
init(text: String, color: NSColor, style: WindowOverlayStyle, isVertical: Bool, isLeftSide: Bool) {
|
|
self.text = text
|
|
self.color = color
|
|
self.style = style
|
|
self.isVertical = isVertical
|
|
self.isLeftSide = isLeftSide
|
|
super.init(frame: .zero)
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
|
|
// The chip's extent along its reading direction, before any rotation.
|
|
private static func chipLength(for text: String) -> CGFloat {
|
|
let width = (text as NSString).size(withAttributes: [.font: font]).width
|
|
return min(maxLength, (width + horizontalPadding * 2).rounded(.up))
|
|
}
|
|
|
|
// Rotation is a property of the drawing, not of the panel, so the
|
|
// panel's own width and height swap over for a vertical chip.
|
|
static func size(text: String, style: WindowOverlayStyle, isVertical: Bool) -> NSSize {
|
|
switch style {
|
|
case .dot:
|
|
return NSSize(width: dotDiameter, height: dotDiameter)
|
|
case .chip:
|
|
let length = chipLength(for: text)
|
|
return isVertical
|
|
? NSSize(width: thickness, height: length)
|
|
: NSSize(width: length, height: thickness)
|
|
}
|
|
}
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
guard let ctx = NSGraphicsContext.current?.cgContext else { return }
|
|
|
|
if style == .dot {
|
|
color.setFill()
|
|
NSBezierPath(ovalIn: bounds).fill()
|
|
return
|
|
}
|
|
|
|
ctx.saveGState()
|
|
defer { ctx.restoreGState() }
|
|
|
|
// Lay the chip out once, horizontally, around the origin — then
|
|
// rotate the whole coordinate space if it belongs on a side edge.
|
|
// One layout path serves both orientations, so the vertical case
|
|
// can't drift out of step with the horizontal one.
|
|
ctx.translateBy(x: bounds.midX, y: bounds.midY)
|
|
if isVertical { ctx.rotate(by: isLeftSide ? .pi / 2 : -.pi / 2) }
|
|
|
|
let length = Self.chipLength(for: text)
|
|
let rect = NSRect(
|
|
x: -length / 2, y: -Self.thickness / 2, width: length, height: Self.thickness)
|
|
color.setFill()
|
|
NSBezierPath(roundedRect: rect, xRadius: Self.thickness / 2, yRadius: Self.thickness / 2).fill()
|
|
|
|
let paragraph = NSMutableParagraphStyle()
|
|
paragraph.alignment = .center
|
|
paragraph.lineBreakMode = .byTruncatingTail
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
|
.font: Self.font,
|
|
.foregroundColor: ProfileColor.contrastingTextColor(on: color),
|
|
.paragraphStyle: paragraph,
|
|
]
|
|
let attributed = NSAttributedString(string: text, attributes: attributes)
|
|
// Drawn into a rect exactly one line tall and centred on the
|
|
// chip's midline. Handing it the chip's full height instead would
|
|
// top-align the text inside it — the reason the old label sat
|
|
// high rather than centred.
|
|
let lineHeight = attributed.size().height
|
|
attributed.draw(in: NSRect(
|
|
x: rect.minX + Self.horizontalPadding, y: rect.midY - lineHeight / 2,
|
|
width: rect.width - Self.horizontalPadding * 2, height: lineHeight))
|
|
}
|
|
}
|
|
|
|
final class WindowOverlay: NSObject, NSWindowDelegate {
|
|
let pid: pid_t
|
|
private let axWindow: AXUIElement
|
|
private var observer: AXObserver?
|
|
private let panel: NSPanel
|
|
private let tagView: TagView
|
|
private let profileName: String
|
|
private var placement: WindowOverlayPlacement
|
|
// True only while `reposition` is moving the panel itself — see
|
|
// `windowDidMove`, which must ignore those moves.
|
|
private var isRepositioning = false
|
|
private var dragSettle: DispatchWorkItem?
|
|
private var isMinimized = false
|
|
|
|
init?(profileName: String, color: NSColor, pid: pid_t) {
|
|
let axApp = AXUIElementCreateApplication(pid)
|
|
guard let window = Self.firstWindow(of: axApp) else { return nil }
|
|
self.pid = pid
|
|
self.axWindow = window
|
|
self.profileName = profileName
|
|
let placement = WindowOverlayPlacement.load(for: profileName)
|
|
self.placement = placement
|
|
|
|
let style = WindowOverlayPosition.style
|
|
let tagView = TagView(
|
|
text: profileName, color: color, style: style,
|
|
isVertical: style == .chip && placement.isVertical,
|
|
isLeftSide: placement.corner.isLeft)
|
|
tagView.autoresizingMask = [.width, .height]
|
|
self.tagView = tagView
|
|
|
|
let panel = NSPanel(
|
|
contentRect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1)),
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
backing: .buffered, defer: false)
|
|
panel.isOpaque = false
|
|
panel.backgroundColor = .clear
|
|
panel.hasShadow = true
|
|
panel.level = .floating
|
|
// Draggable (so it can be moved off whatever it's obstructing),
|
|
// but .nonactivatingPanel keeps a click/drag from stealing focus
|
|
// away from the Claude window underneath.
|
|
panel.isMovableByWindowBackground = true
|
|
panel.collectionBehavior = [.stationary, .ignoresCycle]
|
|
panel.contentView = tagView
|
|
|
|
self.panel = panel
|
|
super.init()
|
|
panel.delegate = self
|
|
|
|
guard reposition() else { return nil }
|
|
startObserving()
|
|
}
|
|
|
|
// A cold Electron launch can take a second or more between the
|
|
// process starting (which is what triggers `updateWindowOverlays`,
|
|
// via NSWorkspace's launch notification) and its first window
|
|
// actually existing — the same race ClaudeControl.focus's
|
|
// waitForWindow already handles for the same reason. `init?` can't
|
|
// retry on its own (once it returns nil, that attempt is done), so
|
|
// this polls it every quarter second for up to ~3s before giving up.
|
|
static func attach(
|
|
profileName: String, color: NSColor, pid: pid_t, attemptsRemaining: Int = 12,
|
|
completion: @escaping (WindowOverlay?) -> Void
|
|
) {
|
|
if let overlay = WindowOverlay(profileName: profileName, color: color, pid: pid) {
|
|
completion(overlay)
|
|
} else if attemptsRemaining > 0 {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
|
attach(
|
|
profileName: profileName, color: color, pid: pid, attemptsRemaining: attemptsRemaining - 1,
|
|
completion: completion)
|
|
}
|
|
} else {
|
|
completion(nil)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
dragSettle?.cancel()
|
|
stopObserving()
|
|
panel.orderOut(nil)
|
|
}
|
|
|
|
private static func firstWindow(of axApp: AXUIElement) -> AXUIElement? {
|
|
var value: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(axApp, kAXWindowsAttribute as CFString, &value) == .success,
|
|
let windows = value as? [AXUIElement], let first = windows.first
|
|
else { return nil }
|
|
return first
|
|
}
|
|
|
|
private static func frame(of window: AXUIElement) -> CGRect? {
|
|
var posValue: CFTypeRef?
|
|
var sizeValue: CFTypeRef?
|
|
guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posValue) == .success,
|
|
AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeValue) == .success,
|
|
CFGetTypeID(posValue) == AXValueGetTypeID(), CFGetTypeID(sizeValue) == AXValueGetTypeID()
|
|
else { return nil }
|
|
var point = CGPoint.zero
|
|
var size = CGSize.zero
|
|
guard AXValueGetValue(posValue as! AXValue, .cgPoint, &point),
|
|
AXValueGetValue(sizeValue as! AXValue, .cgSize, &size)
|
|
else { return nil }
|
|
return CGRect(origin: point, size: size)
|
|
}
|
|
|
|
private static func screen(containing axFrame: CGRect) -> NSScreen? {
|
|
NSScreen.screens.first { $0.frame.minX <= axFrame.midX && axFrame.midX <= $0.frame.maxX } ?? NSScreen.main
|
|
}
|
|
|
|
// AX coordinates are top-left-origin (screen top = y 0); AppKit screen
|
|
// coordinates are bottom-left-origin. Returns the window's frame
|
|
// translated into AppKit's space.
|
|
private static func appKitFrame(of axFrame: CGRect, on screen: NSScreen) -> NSRect {
|
|
NSRect(x: axFrame.origin.x, y: screen.frame.maxY - axFrame.origin.y - axFrame.height,
|
|
width: axFrame.width, height: axFrame.height)
|
|
}
|
|
|
|
private var currentGeometry: (window: NSRect, screen: NSScreen)? {
|
|
guard let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame)
|
|
else { return nil }
|
|
return (Self.appKitFrame(of: axFrame, on: screen), screen)
|
|
}
|
|
|
|
// The tag's frame for a given window frame: measured inward from its
|
|
// anchored corner, with the offsets clamped so it always stays over
|
|
// the window no matter how small that window gets. Only the *drawn*
|
|
// position is clamped — see WindowOverlayPlacement.
|
|
//
|
|
// Then clamped again, into whatever part of the window is actually on
|
|
// screen. A window dragged half off the edge takes its anchored corner
|
|
// with it, and a tag that follows it out of view identifies nothing —
|
|
// the whole point is telling, at a glance, which profile the window you
|
|
// can still see belongs to. Clamped to the window's *visible* portion
|
|
// rather than to the screen at large so the tag stays on the thing it
|
|
// labels instead of drifting off onto its own. `visibleFrame` keeps it
|
|
// clear of the menu bar and Dock, which would hide it just as
|
|
// effectively as the screen edge.
|
|
//
|
|
// Both clamps are presentational only — neither is written back to
|
|
// `placement`, which changes solely when the user drags the tag. So the
|
|
// anchor is remembered throughout, and the tag returns to it the moment
|
|
// the window is fully back on screen.
|
|
private func frame(window: NSRect, screen: NSScreen) -> NSRect {
|
|
let style = WindowOverlayPosition.style
|
|
let size = TagView.size(
|
|
text: profileName, style: style,
|
|
isVertical: style == .chip && placement.isVertical)
|
|
let corner = placement.corner
|
|
let insetX = placement.insetX
|
|
.clamped(to: 0...max(0, window.width - size.width))
|
|
let insetY = placement.insetY
|
|
.clamped(to: 0...max(0, window.height - size.height))
|
|
var origin = NSPoint(
|
|
x: corner.isLeft ? window.minX + insetX : window.maxX - insetX - size.width,
|
|
y: corner.isTop ? window.maxY - insetY - size.height : window.minY + insetY)
|
|
|
|
// A window entirely off screen leaves nothing to clamp into; fall
|
|
// back to the screen so the tag stays reachable rather than being
|
|
// pinned to an empty rect.
|
|
let onScreen = window.intersection(screen.visibleFrame)
|
|
let bounds = onScreen.isEmpty ? screen.visibleFrame : onScreen
|
|
origin.x = origin.x.clamped(to: bounds.minX...max(bounds.minX, bounds.maxX - size.width))
|
|
origin.y = origin.y.clamped(to: bounds.minY...max(bounds.minY, bounds.maxY - size.height))
|
|
return NSRect(origin: origin, size: size)
|
|
}
|
|
|
|
// Puts the tag where the current placement says it belongs. Returns
|
|
// false (and hides the tag) if the window has no readable frame right
|
|
// now — minimized, or Accessibility not granted.
|
|
@discardableResult
|
|
private func reposition() -> Bool {
|
|
guard let (windowFrame, screen) = currentGeometry else {
|
|
panel.orderOut(nil)
|
|
return false
|
|
}
|
|
let style = WindowOverlayPosition.style
|
|
tagView.style = style
|
|
tagView.isVertical = style == .chip && placement.isVertical
|
|
tagView.isLeftSide = placement.corner.isLeft
|
|
tagView.needsDisplay = true
|
|
|
|
isRepositioning = true
|
|
defer { isRepositioning = false }
|
|
let tagFrame = frame(window: windowFrame, screen: screen)
|
|
panel.setFrame(tagFrame, display: true)
|
|
// The shadow is derived from the content's alpha, so a shape or
|
|
// size change leaves a stale one behind without this.
|
|
panel.invalidateShadow()
|
|
// Positioned either way — only the showing is conditional, so a
|
|
// hidden tag still knows where it belongs and can be revealed later
|
|
// without recomputing anything.
|
|
if isMinimized || !isTargetVisible(tagFrame: tagFrame) {
|
|
panel.orderOut(nil)
|
|
} else if !panel.isVisible {
|
|
panel.orderFrontRegardless()
|
|
}
|
|
return true
|
|
}
|
|
|
|
// Re-evaluate whether the tag should currently be on screen. Called
|
|
// when some app activates, which is the usual way a window ends up
|
|
// buried or uncovered without moving at all.
|
|
func refreshVisibility() {
|
|
reposition()
|
|
}
|
|
|
|
// A tag is a floating panel, which puts it above every ordinary window
|
|
// on the system rather than merely above the window it belongs to. Left
|
|
// alone it hovers over the browser, the editor, everything — even when
|
|
// its own window is buried or on another Space.
|
|
//
|
|
// There is no cross-process way to attach one window above another:
|
|
// `addChildWindow` is same-process only, and the private ordering call
|
|
// window managers use is a one-shot operation that goes stale the
|
|
// moment anything else reorders, so it wouldn't avoid this work either.
|
|
// So the tag is shown only when the window it labels is genuinely
|
|
// visible underneath it.
|
|
//
|
|
// The window server returns its list strictly front-to-back, which
|
|
// answers both halves in one pass: walk forward, and anything
|
|
// overlapping the tag before we reach our own window is covering it.
|
|
// Reaching our window with nothing in the way means the tag is showing
|
|
// real estate that actually belongs to that window; never reaching it
|
|
// means the window isn't on screen at all — minimized, hidden, or on
|
|
// another Space — and the tag has nothing to label.
|
|
private func isTargetVisible(tagFrame: NSRect) -> Bool {
|
|
guard let listing = CGWindowListCopyWindowInfo(
|
|
[.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]]
|
|
else { return true } // can't tell — better a stray tag than a missing one
|
|
|
|
let tag = Self.cgRect(of: tagFrame)
|
|
let ownPid = getpid()
|
|
for entry in listing {
|
|
guard let owner = entry[kCGWindowOwnerPID as String] as? pid_t,
|
|
let boundsDict = entry[kCGWindowBounds as String],
|
|
let bounds = CGRect(dictionaryRepresentation: boundsDict as! CFDictionary)
|
|
else { continue }
|
|
// Identified by owner alone, deliberately. Matching the exact
|
|
// rect meant comparing a freshly-read AX frame against the
|
|
// window server's snapshot, and mid-move the two disagree —
|
|
// AX already reports the new position while the listing still
|
|
// has the old one. The match then failed, the walk continued
|
|
// past our own window, and the first unrelated window that
|
|
// happened to overlap the tag "occluded" it. Whether that
|
|
// occurred came down to what was nearby, which is why it
|
|
// looked as though only certain drag directions broke it.
|
|
//
|
|
// A dialog of the same app landing here counts as reaching our
|
|
// window, which is right: Claude covering its own window is
|
|
// not a reason to disown the tag.
|
|
if owner == pid, bounds.intersects(tag) { return true }
|
|
// Our own tags are in this list too, and one tag sitting over
|
|
// another's window must not hide it.
|
|
if owner == ownPid { continue }
|
|
// Only ordinary windows can bury another ordinary window.
|
|
// Everything above that band — the menu bar, the Dock,
|
|
// notification banners, and the invisible one-pixel markers
|
|
// some utilities park in a screen corner — is permanently in
|
|
// front of everything and would veto the tag forever. One such
|
|
// marker at the bottom-left corner is what made a tag vanish
|
|
// there, and only there: it takes a move off two edges at once
|
|
// for the tag to clamp into that exact pixel.
|
|
guard (entry[kCGWindowLayer as String] as? Int) == 0 else { continue }
|
|
if bounds.intersects(tag) { return false }
|
|
}
|
|
return false
|
|
}
|
|
|
|
// AppKit is bottom-left-origin per screen; the window server is
|
|
// top-left-origin from the primary screen (screens[0], the one with the
|
|
// menu bar).
|
|
private static func cgRect(of rect: NSRect) -> CGRect {
|
|
let primaryMaxY = NSScreen.screens.first?.frame.maxY ?? rect.maxY
|
|
return CGRect(x: rect.minX, y: primaryMaxY - rect.maxY, width: rect.width, height: rect.height)
|
|
}
|
|
|
|
// MARK: - User placement
|
|
|
|
// Fires for every panel move — ours (from `reposition`, when the
|
|
// tracked window itself moves/resizes) and the user's (dragging the
|
|
// tag). Only the latter should redefine the placement, which is what
|
|
// the `isRepositioning` guard filters for.
|
|
//
|
|
// Re-deriving the offset from our own moves too looks like it should
|
|
// be a harmless no-op — recomputing the very offset we just positioned
|
|
// against — but it isn't, because the two halves read the window at
|
|
// different instants. AppKit posts this notification synchronously
|
|
// from inside `setFrame`, and during a live window drag the AX frame
|
|
// read here is already newer than the one `reposition` derived the
|
|
// origin from, so each move event bakes in the few points the window
|
|
// travelled in between. Those errors accumulate across the hundreds
|
|
// of events one drag produces, until the offset saturates and the tag
|
|
// has walked clear across its window — off-screen entirely, if that
|
|
// edge of the window is.
|
|
func windowDidMove(_ notification: Notification) {
|
|
guard !isRepositioning else { return }
|
|
scheduleDragSettle()
|
|
}
|
|
|
|
// A tag drag emits a continuous stream of moves, and re-anchoring on
|
|
// each one would snap the tag out from under the cursor mid-gesture
|
|
// (and flip its orientation repeatedly on the way past an edge). So
|
|
// the placement is only committed once the moves stop, which is as
|
|
// close to a "drag ended" signal as a background-movable window gets
|
|
// — NSWindow has no such delegate callback, unlike live resize.
|
|
private func scheduleDragSettle() {
|
|
dragSettle?.cancel()
|
|
let work = DispatchWorkItem { [weak self] in self?.commitUserPlacement() }
|
|
dragSettle = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: work)
|
|
}
|
|
|
|
private func commitUserPlacement() {
|
|
guard let windowFrame = currentGeometry?.window else { return }
|
|
let tagFrame = panel.frame
|
|
let centre = NSPoint(x: tagFrame.midX, y: tagFrame.midY)
|
|
let corner = WindowOverlayCorner.nearest(to: centre, in: windowFrame)
|
|
|
|
// Orientation follows whichever edge the tag ended up nearest: a
|
|
// chip against a side reads vertically, so it can sit close in
|
|
// without covering the window's own controls. Dots have no
|
|
// reading direction, so they're never rotated.
|
|
let toSide = min(centre.x - windowFrame.minX, windowFrame.maxX - centre.x)
|
|
let toTopOrBottom = min(windowFrame.maxY - centre.y, centre.y - windowFrame.minY)
|
|
let isVertical = WindowOverlayPosition.style == .chip && toSide < toTopOrBottom
|
|
|
|
// Offsets run inward from the anchored corner. Stored unclamped at
|
|
// the top end (a drag outside the window is clamped to zero, but a
|
|
// long reach into a wide window is kept in full) so a later resize
|
|
// can restore it.
|
|
let insetX = corner.isLeft
|
|
? tagFrame.minX - windowFrame.minX : windowFrame.maxX - tagFrame.maxX
|
|
let insetY = corner.isTop
|
|
? windowFrame.maxY - tagFrame.maxY : tagFrame.minY - windowFrame.minY
|
|
|
|
placement = WindowOverlayPlacement(
|
|
corner: corner, insetX: max(0, insetX), insetY: max(0, insetY), isVertical: isVertical)
|
|
placement.save(for: profileName)
|
|
|
|
// Snap into the committed placement — this is what applies a
|
|
// rotation the drag just earned, and squares the tag up against
|
|
// its corner.
|
|
reposition()
|
|
}
|
|
|
|
// MARK: - Live tracking
|
|
|
|
private func startObserving() {
|
|
var newObserver: AXObserver?
|
|
guard AXObserverCreate(pid, WindowOverlay.axCallback, &newObserver) == .success, let newObserver
|
|
else { return }
|
|
observer = newObserver
|
|
|
|
let refcon = Unmanaged.passUnretained(self).toOpaque()
|
|
for name in [
|
|
kAXMovedNotification, kAXResizedNotification, kAXUIElementDestroyedNotification,
|
|
kAXWindowMiniaturizedNotification, kAXWindowDeminiaturizedNotification,
|
|
] {
|
|
AXObserverAddNotification(newObserver, axWindow, name as CFString, refcon)
|
|
}
|
|
CFRunLoopAddSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(newObserver), .defaultMode)
|
|
}
|
|
|
|
private func stopObserving() {
|
|
guard let observer else { return }
|
|
CFRunLoopRemoveSource(CFRunLoopGetCurrent(), AXObserverGetRunLoopSource(observer), .defaultMode)
|
|
self.observer = nil
|
|
}
|
|
|
|
private static let axCallback: AXObserverCallback = { _, _, notification, refcon in
|
|
guard let refcon else { return }
|
|
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
|
|
switch notification as String {
|
|
case kAXUIElementDestroyedNotification:
|
|
overlay.panel.orderOut(nil)
|
|
case kAXWindowMiniaturizedNotification:
|
|
overlay.isMinimized = true
|
|
overlay.reposition()
|
|
case kAXWindowDeminiaturizedNotification:
|
|
overlay.isMinimized = false
|
|
overlay.reposition()
|
|
default:
|
|
overlay.reposition()
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Comparable {
|
|
func clamped(to range: ClosedRange<Self>) -> Self {
|
|
min(max(self, range.lowerBound), range.upperBound)
|
|
}
|
|
}
|