Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5191a38f7a | ||
|
|
1d3a539edd |
@@ -4,6 +4,9 @@
|
|||||||
icon/claude-src/
|
icon/claude-src/
|
||||||
icon/AppIcon.iconset/
|
icon/AppIcon.iconset/
|
||||||
|
|
||||||
|
# Session handoff notes — local only, never committed
|
||||||
|
CHECKPOINT.md
|
||||||
|
|
||||||
# Local reference material (e.g. nested checkouts of other projects) — not
|
# Local reference material (e.g. nested checkouts of other projects) — not
|
||||||
# part of this project, never meant to be committed.
|
# part of this project, never meant to be committed.
|
||||||
.scratch/
|
.scratch/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Reads live state from LiveState (push-based, no polling) and calls
|
// Reads live state from LiveState (push-based, no polling) and calls
|
||||||
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
|
// ProfileStore/ClaudeControl in-process — no subprocess, no text parsing.
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ApplicationServices
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveStateDelegate {
|
||||||
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
@@ -9,6 +10,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
let liveState = LiveState()
|
let liveState = LiveState()
|
||||||
private var currentInfos: [ProfileInfo] = []
|
private var currentInfos: [ProfileInfo] = []
|
||||||
private var collisionAlertShown = false
|
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) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
menu.delegate = self
|
menu.delegate = self
|
||||||
@@ -19,6 +25,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
|
|
||||||
liveState.delegate = self
|
liveState.delegate = self
|
||||||
ManageWindowController.shared.onProfilesChanged = { [weak self] in self?.liveState.reconcile() }
|
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()
|
liveState.reconcile()
|
||||||
|
|
||||||
// Launching this app fresh with nothing running means there's no
|
// Launching this app fresh with nothing running means there's no
|
||||||
@@ -50,6 +65,107 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, LiveSt
|
|||||||
currentInfos = infos
|
currentInfos = infos
|
||||||
applyTitle(infos)
|
applyTitle(infos)
|
||||||
ManageWindowController.shared.update(infos)
|
ManageWindowController.shared.update(infos)
|
||||||
|
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,
|
||||||
|
// which tears down its AXObserver and hides the tag panel.
|
||||||
|
private func updateWindowOverlays(_ infos: [ProfileInfo]) {
|
||||||
|
let managed = infos.filter { $0.running && $0.profile.name != "default" }
|
||||||
|
let managedNames = Set(managed.map(\.profile.name))
|
||||||
|
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 }
|
||||||
|
let name = info.profile.name
|
||||||
|
pendingWindowOverlays.insert(name)
|
||||||
|
WindowOverlay.attach(profileName: name, color: ProfileColor.dotColor(for: name), pid: pid) { [weak self] overlay in
|
||||||
|
guard let self else { return }
|
||||||
|
pendingWindowOverlays.remove(name)
|
||||||
|
// The profile may have quit again while this was retrying
|
||||||
|
// 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
|
// Two profiles sharing a dir is a fatal misconfiguration (would
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ enum ClaudeControl {
|
|||||||
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
|
AXUIElementSetAttributeValue(window, kAXMinimizedAttribute as CFString, minimized as CFTypeRef)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func promptForAccessibility() {
|
static func promptForAccessibility() {
|
||||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue(): true] as CFDictionary
|
||||||
_ = AXIsProcessTrustedWithOptions(options)
|
_ = AXIsProcessTrustedWithOptions(options)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ final class ManageWindowController: NSWindowController {
|
|||||||
didSet { profilesVC.onChange = onProfilesChanged }
|
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() {
|
private init() {
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 420, height: 260),
|
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)
|
checkboxWithTitle: "Automatically check for updates", target: nil, action: nil)
|
||||||
private let launchAtLoginCheckbox = NSButton(checkboxWithTitle: "Launch at Login", 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 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!
|
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() {
|
override func loadView() {
|
||||||
view = NSView(frame: NSRect(x: 0, y: 0, width: 420, height: 150))
|
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.target = self
|
||||||
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
|
launchAtLoginCheckbox.action = #selector(toggleLaunchAtLogin)
|
||||||
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
|
launchAtLoginCheckbox.state = LaunchAtLogin.isEnabled ? .on : .off
|
||||||
@@ -519,7 +547,7 @@ private final class SettingsViewController: NSViewController {
|
|||||||
checkNowButton.action = #selector(checkNow)
|
checkNowButton.action = #selector(checkNow)
|
||||||
checkNowButton.bezelStyle = .rounded
|
checkNowButton.bezelStyle = .rounded
|
||||||
|
|
||||||
stack = NSStackView(views: [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton])
|
stack = NSStackView(views: [launchAtLoginCheckbox, tagStyleRow, autoUpdateCheckbox, checkNowButton])
|
||||||
stack.orientation = .vertical
|
stack.orientation = .vertical
|
||||||
stack.alignment = .leading
|
stack.alignment = .leading
|
||||||
stack.spacing = 14
|
stack.spacing = 14
|
||||||
@@ -550,12 +578,24 @@ private final class SettingsViewController: NSViewController {
|
|||||||
// is what AutoLayout itself uses to size an unconstrained control —
|
// is what AutoLayout itself uses to size an unconstrained control —
|
||||||
// sidesteps whatever the stack's own aggregation is getting wrong.
|
// sidesteps whatever the stack's own aggregation is getting wrong.
|
||||||
private func updatePreferredSize() {
|
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]
|
let widest = [launchAtLoginCheckbox, autoUpdateCheckbox, checkNowButton]
|
||||||
.map(\.intrinsicContentSize.width)
|
.map(\.intrinsicContentSize.width)
|
||||||
.max() ?? 200
|
.max().map { max($0, rowWidth) } ?? rowWidth
|
||||||
preferredContentSize = NSSize(width: widest + 40, height: stack.fittingSize.height)
|
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() {
|
@objc private func toggleLaunchAtLogin() {
|
||||||
do {
|
do {
|
||||||
if launchAtLoginCheckbox.state == .on {
|
if launchAtLoginCheckbox.state == .on {
|
||||||
|
|||||||
@@ -32,6 +32,30 @@ enum ProfileColor {
|
|||||||
return NSColor(calibratedHue: hue, saturation: saturation, brightness: brightness, alpha: 1.0)
|
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
|
// `dimmed` marks a profile that isn't actually running right now — the
|
||||||
// colored dot still identifies it, but faded, so "open" vs "known but
|
// colored dot still identifies it, but faded, so "open" vs "known but
|
||||||
// closed" is visible without reading a tooltip.
|
// closed" is visible without reading a tooltip.
|
||||||
|
|||||||
@@ -0,0 +1,604 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user