1 Commits
Author SHA1 Message Date
Claude Opus 5andbdeshi 5191a38f7a Fix the window tags: permission, drift, anchoring, and styling
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.
2026-08-05 13:08:11 +06:00
6 changed files with 590 additions and 66 deletions
+87
View File
@@ -2,6 +2,7 @@
// Reads live state from LiveState (push-based, no polling) and calls
// ProfileStore/ClaudeControl in-process no subprocess, no text parsing.
import AppKit
import ApplicationServices
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -11,6 +12,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
private var collisionAlertShown = false
private var windowOverlays: [String: WindowOverlay] = [:]
private var pendingWindowOverlays: Set<String> = []
private var accessibilityPromptShown = false
private var accessibilityRecheckScheduled = false
private var activationObserver: NSObjectProtocol?
func applicationDidFinishLaunching(_ notification: Notification) {
menu.delegate = self
@@ -21,6 +25,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
liveState.delegate = self
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
ManageWindowController.shared.onOverlayStyleChanged = { [weak self] in self?.rebuildWindowOverlays() }
// Every app activation, not just Claude's LiveState deliberately
// filters to the Claude binary, so on its own it never hears that
// some unrelated app came forward, which is exactly when the tags
// need to get out of the way.
activationObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didActivateApplicationNotification, object: nil, queue: .main
) { [weak self] _ in self?.updateOverlayVisibility() }
liveState.reconcile()
// Launching this app fresh with nothing running means there's no
@@ -55,6 +68,47 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
updateWindowOverlays(infos)
}
// Each tag decides for itself whether the window it labels is actually
// visible underneath it (see WindowOverlay.isTargetVisible) this just
// says "now would be a good time to look again". Activation is the
// usual way a window gets buried or uncovered without moving at all,
// which no AX notification reports.
private func updateOverlayVisibility() {
for overlay in windowOverlays.values { overlay.refreshVisibility() }
}
// macOS doesn't notify an app that it has just been granted
// Accessibility, and overlays are otherwise only reconsidered when a
// Claude window launches, quits or activates so a permission granted
// while this is running would do nothing visible until the user
// happened to touch a Claude window, or restarted the app. Polling for
// it costs a cheap local check every couple of seconds, and only while
// the permission is actually missing: the moment it lands, the guard in
// `updateWindowOverlays` passes and this stops rescheduling itself.
//
// Also covers a subtler case replacing the installed bundle and
// relaunching immediately can start the app before TCC has settled on
// the new copy, and a one-shot check there would strand the tags for
// the rest of the session over a denial that was only ever momentary.
private func scheduleAccessibilityRecheck() {
guard !accessibilityRecheckScheduled else { return }
accessibilityRecheckScheduled = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
guard let self else { return }
accessibilityRecheckScheduled = false
updateWindowOverlays(currentInfos)
}
}
// A style change rewrites the tag's shape and size, which is decided
// when its panel is built so the existing ones are dropped (deinit
// tears down each panel and its AXObserver) and rebuilt rather than
// patched in place.
private func rebuildWindowOverlays() {
windowOverlays.removeAll()
updateWindowOverlays(currentInfos)
}
// Shown on every running *managed* profile never on "default",
// which isn't a shannoncoat profile at all, just the real underlying
// Claude install. Removing an entry here deinits its WindowOverlay,
@@ -65,6 +119,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
windowOverlays = windowOverlays.filter { managedNames.contains($0.key) }
pendingWindowOverlays.formIntersection(managedNames)
// A tag is positioned entirely from Accessibility data, so without
// that permission there is nothing to position: `attach` would
// burn its dozen retries per profile and give up without a word,
// which reads as "the feature is broken" rather than "macOS said
// no". Prompt instead the same call `ClaudeControl.focus` makes
// for the same reason.
//
// Worth knowing when this bites, because it looks impossible:
// a local build gets a fresh code identity every time build.sh
// runs, and that silently invalidates an existing grant while
// leaving the app still listed *and still ticked* under Privacy &
// Security Accessibility. Re-granting means removing that stale
// row with "" and adding the newly built app back; toggling it
// off and on again is not enough. (Note that running the binary
// straight out of the bundle from a terminal will appear to work
// regardless it inherits the terminal's grant, not the app's,
// so it's useless for testing this.)
if !managed.isEmpty, !AXIsProcessTrusted() {
if !accessibilityPromptShown {
accessibilityPromptShown = true
ClaudeControl.promptForAccessibility()
}
scheduleAccessibilityRecheck()
return
}
for info in managed
where windowOverlays[info.profile.name] == nil && !pendingWindowOverlays.contains(info.profile.name) {
guard let pid = info.pid else { continue }
@@ -77,8 +157,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
// for its window don't attach a stale overlay if so.
guard currentInfos.contains(where: { $0.profile.name == name && $0.running }) else { return }
windowOverlays[name] = overlay
// A tag created while some other app is frontmost must not
// appear over it `attach` can complete seconds after the
// launch that triggered it, by which point focus has often
// moved on.
updateOverlayVisibility()
}
}
updateOverlayVisibility()
}
// Two profiles sharing a dir is a fatal misconfiguration (would
+1 -1
View File
@@ -182,7 +182,7 @@ enum ClaudeControl {
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
}
private static func promptForAccessibility() {
static func promptForAccessibility() {
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
}
+42 -2
View File
@@ -24,6 +24,13 @@ final class ManageWindowController: NSWindowController {
didSet { profilesVC.onChange = onProfilesChanged }
}
// Called when the window-tag style changes, so open tags can be
// rebuilt in the new style immediately rather than at the next
// launch/quit of a Claude window.
var onOverlayStyleChanged: (() -> Void)? {
didSet { settingsVC.onOverlayStyleChanged = onOverlayStyleChanged }
}
private init() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
@@ -502,11 +509,32 @@ private final class SettingsViewController: NSViewController {
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", target: nil, action: nil)
private let checkNowButton = NSButton(title: "Check for Updates\u{2026}", target: nil, action: nil)
private let tagStyleLabel = NSTextField(labelWithString: "Window tag:")
private let tagStylePopUp = NSPopUpButton(frame: .zero, pullsDown: false)
private var tagStyleRow: NSStackView!
private var stack: NSStackView!
var onOverlayStyleChanged: (() -> Void)?
// Order matches the popup's items, so the selected index maps straight
// onto a style without a title-string comparison.
private static let tagStyles: [(title: String, style: WindowOverlayStyle)] = [
("Name chip", .chip),
("Colored dot", .dot),
]
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
tagStylePopUp.addItems(withTitles: Self.tagStyles.map(\.title))
tagStylePopUp.selectItem(at: Self.tagStyles.firstIndex { $0.style == WindowOverlayPosition.style } ?? 0)
tagStylePopUp.target = self
tagStylePopUp.action = #selector(changeTagStyle)
tagStyleRow = NSStackView(views: [tagStyleLabel, tagStylePopUp])
tagStyleRow.orientation = .horizontal
tagStyleRow.spacing = 8
launchAtLoginCheckbox.target = self
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
@@ -519,7 +547,7 @@ private final class SettingsViewController: NSViewController {
checkNowButton.action = #selector(checkNow)
checkNowButton.bezelStyle = .rounded
stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 14
@@ -550,12 +578,24 @@ private final class SettingsViewController: NSViewController {
// is what AutoLayout itself uses to size an unconstrained control
// sidesteps whatever the stack's own aggregation is getting wrong.
private func updatePreferredSize() {
// Same reasoning for the tag-style row: summed from its two
// controls plus the stack spacing rather than trusting the row's
// own fittingSize.
let rowWidth = tagStyleLabel.intrinsicContentSize.width
+ tagStylePopUp.intrinsicContentSize.width + tagStyleRow.spacing
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
.map(\.intrinsicContentSize.width)
.max() ?? 200
.max().map { max($0, rowWidth) } ?? rowWidth
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
}
@objc private func changeTagStyle() {
let index = tagStylePopUp.indexOfSelectedItem
guard Self.tagStyles.indices.contains(index) else { return }
WindowOverlayPosition.style = Self.tagStyles[index].style
onOverlayStyleChanged?()
}
@objc private func toggleLaunchAtLogin() {
do {
if launchAtLoginCheckbox.state == .on {
+24
View File
@@ -32,6 +32,30 @@ enum ProfileColor {
return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
}
// Light or dark text, whichever reads better on the given background.
// Profile colours come off the full hue wheel (see `dotColor`), whose
// luminance varies enormously yellow against white text is barely
// legible where indigo against black text is worse so this can't be
// one fixed choice.
//
// Chooses on WCAG relative luminance rather than raw brightness,
// because the eye is roughly six times more sensitive to green than to
// blue and an unweighted average badly misjudges both. 0.179 is where
// contrast against white and against black come out equal, so it's the
// threshold that maximises contrast either way. Near-white and
// near-black rather than pure, which reads as less harsh at 10pt
// without measurably costing contrast.
static func contrastingTextColor(on background: NSColor) -> NSColor {
guard let rgb = background.usingColorSpace(.sRGB) else { return .white }
func linear(_ channel: CGFloat) -> CGFloat {
channel <= 0.03928 ? channel / 12.92 : pow((channel + 0.055) / 1.055, 2.4)
}
let luminance = 0.2126 * linear(rgb.redComponent)
+ 0.7152 * linear(rgb.greenComponent)
+ 0.0722 * linear(rgb.blueComponent)
return luminance > 0.179 ? NSColor(white: 0.10, alpha: 1) : NSColor(white: 0.98, alpha: 1)
}
// `dimmed` marks a profile that isn't actually running right now the
// colored dot still identifies it, but faded, so "open" vs "known but
// closed" is visible without reading a tooltip.
+435 -62
View File
@@ -8,29 +8,203 @@
import AppKit
import ApplicationServices
// The tag's position relative to its window's top-right corner, shared
// across every open tag and persisted so a drag survives a relaunch.
// Anchored from the right/top edges (not left) because that's the corner
// that stays put under the common resize gesture (dragging the bottom-right
// handle) an offset-from-left would drift away from a user's chosen spot
// every time the window changed width.
enum WindowOverlayPosition {
private static let rightInsetKey = "WindowOverlayRightInset"
private static let topInsetKey = "WindowOverlayTopInset"
// 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.
static let defaultRightInset: CGFloat = 10
static let defaultTopInset: CGFloat = 6
var corner: WindowOverlayCorner = .topRight
var insetX: CGFloat = 8
var insetY: CGFloat = 6
var isVertical: Bool = false
static var rightInset: CGFloat {
get { (UserDefaults.standard.object(forKey: rightInsetKey) as? Double).map { CGFloat($0) } ?? defaultRightInset }
set { UserDefaults.standard.set(Double(newValue), forKey: rightInsetKey) }
private static func key(_ field: String, _ profile: String) -> String {
"WindowOverlay\(field).\(profile)"
}
static var topInset: CGFloat {
get { (UserDefaults.standard.object(forKey: topInsetKey) as? Double).map { CGFloat($0) } ?? defaultTopInset }
set { UserDefaults.standard.set(Double(newValue), forKey: topInsetKey) }
// 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))
}
}
@@ -39,17 +213,34 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
private let axWindow: AXUIElement
private var observer: AXObserver?
private let panel: NSPanel
private static let tagSize = NSSize(width: 110, height: 20)
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: Self.tagSize),
contentRect: NSRect(origin: .zero, size: NSSize(width: 1, height: 1)),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false)
panel.isOpaque = false
@@ -61,22 +252,8 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
// away from the Claude window underneath.
panel.isMovableByWindowBackground = true
panel.collectionBehavior = [.stationary, .ignoresCycle]
panel.contentView = tagView
let background = NSView(frame: NSRect(origin: .zero, size: Self.tagSize))
background.wantsLayer = true
background.layer?.backgroundColor = color.cgColor
background.layer?.cornerRadius = 5
let label = NSTextField(labelWithString: profileName)
label.frame = NSRect(origin: .zero, size: Self.tagSize)
label.alignment = .center
label.font = .systemFont(ofSize: 11, weight: .semibold)
label.textColor = .white
label.drawsBackground = false
label.lineBreakMode = .byTruncatingTail
background.addSubview(label)
panel.contentView = background
self.panel = panel
super.init()
panel.delegate = self
@@ -110,6 +287,7 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
}
deinit {
dragSettle?.cancel()
stopObserving()
panel.orderOut(nil)
}
@@ -149,43 +327,232 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
width: axFrame.width, height: axFrame.height)
}
// Positions the tag using the persisted (or default) offset from the
// window's top-right corner. Returns false (and hides the tag) if the
// window has no readable frame right now minimized, or Accessibility
// not granted.
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 axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else {
guard let (windowFrame, screen) = currentGeometry else {
panel.orderOut(nil)
return false
}
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
let origin = NSPoint(
x: windowFrame.maxX - WindowOverlayPosition.rightInset - Self.tagSize.width,
y: windowFrame.maxY - WindowOverlayPosition.topInset - Self.tagSize.height)
panel.setFrameOrigin(origin)
if !panel.isVisible { panel.orderFrontRegardless() }
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). Either way, re-deriving the offset from the panel's actual
// current position and persisting it is correct: our own moves just
// recompute the same offset they were placed with, a no-op; a user
// drag captures the new spot they chose so it survives the next
// reposition and the next launch. Clamped to stay within the window
// so a sloppy drag can't wander off it entirely.
// 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 let axFrame = Self.frame(of: axWindow), let screen = Self.screen(containing: axFrame) else { return }
let windowFrame = Self.appKitFrame(of: axFrame, on: screen)
let panelOrigin = panel.frame.origin
let rightInset = (windowFrame.maxX - (panelOrigin.x + Self.tagSize.width))
.clamped(to: 0...(max(0, windowFrame.width - Self.tagSize.width)))
let topInset = (windowFrame.maxY - (panelOrigin.y + Self.tagSize.height))
.clamped(to: 0...(max(0, windowFrame.height - Self.tagSize.height)))
WindowOverlayPosition.rightInset = rightInset
WindowOverlayPosition.topInset = topInset
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
@@ -216,8 +583,14 @@ final class WindowOverlay: NSObject, NSWindowDelegate {
guard let refcon else { return }
let overlay = Unmanaged<WindowOverlay>.fromOpaque(refcon).takeUnretainedValue()
switch notification as String {
case kAXUIElementDestroyedNotification, kAXWindowMiniaturizedNotification:
case kAXUIElementDestroyedNotification:
overlay.panel.orderOut(nil)
case kAXWindowMiniaturizedNotification:
overlay.isMinimized = true
overlay.reposition()
case kAXWindowDeminiaturizedNotification:
overlay.isMinimized = false
overlay.reposition()
default:
overlay.reposition()
}
+1 -1
View File
@@ -1 +1 @@
0.0.5
0.0.7