Skip to content

Commit 82f9dcf

Browse files
angusbezzinaclaude
andcommitted
Overlay UX (cli-k0z7h): per-window child overlay, anchored composer, Lucide icon toolbar
Fix the three demo UX issues, root-caused first (composer landed on the AXWindow because nearestIdentified escalated past label-less SwiftUI AXGroups, compounded by an NSScreen.main-vs-primary coordinate assumption): - Window model + layering (issue 2): OverlayController rewritten to attach the overlay as a CHILD window of the host app's own window (addChildWindow .above) instead of a status-bar float over the whole desktop; NSApp.activate on start; a single per-window axOrigin transform (no NSScreen.main assumption) shared by click/highlight/composer, correct on any display. - Composer anchored (issue 1): nearestIdentified no longer climbs to AXWindow; composer placed adjacent to the selected element (ComposerPlacement: below/flip -above, on-screen clamp, accent caret connector), tracked with the selection. - Icon toolbar (issue 3): compact dark Lucide icon pill (hand-ported Lucide glyphs as SwiftUI Shapes, offline/zero-dep) with tooltips + pending badge; every icon maps to a real capability. Build clean (Swift 6, warnings as errors); 40 tests pass (added ComposerPlacement + Lucide + coordinate tests); probe PASS (hitTest resolves to the control). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bc0e0b3 commit 82f9dcf

10 files changed

Lines changed: 963 additions & 87 deletions

Sources/AnnotKit/Coordinates.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,16 @@ public enum ScreenSpace {
3737
height: rect.height
3838
)
3939
}
40+
41+
/// AX top-left origin of a Cocoa (bottom-left, y-up) window frame.
42+
/// ADD to a window-local top-left (y-down) point -> AX-screen point.
43+
/// SUBTRACT from an AX-screen point/frame -> window-local point.
44+
/// `primaryHeight` is `NSScreen.screens.first!.frame.height` (the menu-bar/
45+
/// origin display — NOT `NSScreen.main`, which is the active screen and is
46+
/// the source of the single-display bug). Because the origin is fixed per
47+
/// window, click, highlight, and composer all share one transform: add
48+
/// `axOrigin` going into the AX point query, subtract it placing overlays.
49+
public static func windowAXOrigin(cocoaFrame: CGRect, primaryHeight: CGFloat) -> CGPoint {
50+
CGPoint(x: cocoaFrame.minX, y: primaryHeight - cocoaFrame.maxY)
51+
}
4052
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import CoreGraphics
2+
3+
/// Where to put the note composer relative to the selected element, expressed in
4+
/// the overlay's window-local space (top-left origin, y grows down — the same
5+
/// space the highlight and the `.offset` chrome live in).
6+
///
7+
/// Pure geometry, so it is unit-tested without SwiftUI. The catcher feeds the AX
8+
/// point query by ADDING the host window's `axOrigin`; everything drawn on the
9+
/// overlay (highlight, composer, caret) SUBTRACTS it. Sharing that one transform
10+
/// is why the click, the highlight, and the composer agree by construction on any
11+
/// display, and why the primary-at-origin case (`axOrigin == .zero`) is a no-op.
12+
///
13+
/// The card is placed adjacent to the element — below it by default, flipped
14+
/// above when it would spill off the bottom — and always fully clamped inside the
15+
/// surface. A caret (small pointer) ties the card back to the element: it points
16+
/// up toward the element when the card is below, down when the card is flipped
17+
/// above, and slides horizontally to line up with the element's center.
18+
struct ComposerPlacement: Equatable {
19+
/// Card top-left in window-local space; feed straight into `.offset`.
20+
var origin: CGPoint
21+
/// Caret points up (card below the element) vs down (card flipped above it).
22+
var caretPointsUp: Bool
23+
/// Horizontal caret offset from the card's center, aligning it with the
24+
/// element's horizontal center and clamped so it never rides a rounded corner.
25+
var caretDX: CGFloat
26+
27+
/// Resolve placement for a selected element.
28+
/// - Parameters:
29+
/// - elementFrame: the element's frame in AX-screen coordinates.
30+
/// - axOrigin: AX top-left origin of the host window (subtracted to reach
31+
/// window-local space; see ``ScreenSpace/windowAXOrigin(cocoaFrame:primaryHeight:)``).
32+
/// - surfaceSize: window-local surface size, used to clamp the card fully
33+
/// on-screen.
34+
/// - composerSize: estimated card size, for the clamp and the flip decision.
35+
/// - gap: spacing between the element and the card (and the surface edge).
36+
/// - caretCornerInset: how far the caret must stay from the card's side
37+
/// edges so it never overlaps a rounded corner.
38+
static func resolve(
39+
elementFrame: CGRect,
40+
axOrigin: CGPoint,
41+
surfaceSize: CGSize,
42+
composerSize: CGSize,
43+
gap: CGFloat = 8,
44+
caretCornerInset: CGFloat = 18
45+
) -> ComposerPlacement {
46+
// Element in window-local (y-down) space.
47+
let ex = elementFrame.minX - axOrigin.x
48+
let ey = elementFrame.minY - axOrigin.y
49+
let elementCenterX = ex + elementFrame.width / 2
50+
51+
// Horizontal: left-align with the element, clamped so the whole card fits.
52+
let maxX = max(gap, surfaceSize.width - composerSize.width - gap)
53+
let x = min(max(gap, ex), maxX)
54+
55+
// Vertical: below the element by default; flip above when it would spill
56+
// off the bottom (the diagnosis's window-selected (640,790) off-screen
57+
// spill). Then clamp the top on-screen.
58+
var caretPointsUp = true
59+
var y = ey + elementFrame.height + gap
60+
if y + composerSize.height > surfaceSize.height - gap {
61+
y = ey - composerSize.height - gap
62+
caretPointsUp = false
63+
}
64+
y = max(gap, y)
65+
66+
// Caret: align with the element center, clamped within the card.
67+
let cardCenterX = x + composerSize.width / 2
68+
let caretLimit = max(0, composerSize.width / 2 - caretCornerInset)
69+
let caretDX = min(max(-caretLimit, elementCenterX - cardCenterX), caretLimit)
70+
71+
return ComposerPlacement(
72+
origin: CGPoint(x: x, y: y),
73+
caretPointsUp: caretPointsUp,
74+
caretDX: caretDX
75+
)
76+
}
77+
}

0 commit comments

Comments
 (0)