diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 08b6342..1fd3545 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -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 = [] + 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 diff --git a/Sources/ClaudeControl.swift b/Sources/ClaudeControl.swift index 6409009..cc72099 100644 --- a/Sources/ClaudeControl.swift +++ b/Sources/ClaudeControl.swift @@ -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) } diff --git a/Sources/ManageWindow.swift b/Sources/ManageWindow.swift index 7f2d35b..9ec5da0 100644 --- a/Sources/ManageWindow.swift +++ b/Sources/ManageWindow.swift @@ -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 { diff --git a/Sources/ProfileColor.swift b/Sources/ProfileColor.swift index b60c4b6..1f42b9b 100644 --- a/Sources/ProfileColor.swift +++ b/Sources/ProfileColor.swift @@ -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. diff --git a/Sources/WindowOverlay.swift b/Sources/WindowOverlay.swift index 338ed5d..fd0ad5f 100644 --- a/Sources/WindowOverlay.swift +++ b/Sources/WindowOverlay.swift @@ -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.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() } diff --git a/VERSION b/VERSION index bbdeab6..5a5831a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.5 +0.0.7