Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
fe0dca4
feat(overlay): exit annotate mode via a far-right X control
angusbezzina Aug 8, 2026
42de397
feat(overlay): exit annotate mode via a far-right X control (VRT-cne0.4)
angusbezzina Aug 8, 2026
00fa0de
feat(marquee): pure frame-selection resolution rule
angusbezzina Aug 8, 2026
413d56d
feat(marquee): pure frame-selection resolution rule (VRT-cne0.5)
angusbezzina Aug 8, 2026
c40a4d2
chore: ignore agent worktree directories
angusbezzina Aug 8, 2026
5e99ca6
feat(marquee): session entry point + persisted frame on notes
angusbezzina Aug 8, 2026
c221644
feat(marquee): session entry point + persisted frame on notes (VRT-cn…
angusbezzina Aug 8, 2026
997577a
fix(marquee): clear the region measuring origin when widening
angusbezzina Aug 8, 2026
7ffbba3
docs(marquee): state the click-routing caller contract on select(inAX…
angusbezzina Aug 8, 2026
53dca4f
feat(marquee): macOS adapters resolve a drawn frame to an element
angusbezzina Aug 8, 2026
c273dde
feat(marquee): macOS adapters resolve a drawn frame to an element (VR…
angusbezzina Aug 8, 2026
33d9822
test(marquee): probe sub-phase 7g pins the overlay out of every ladder
angusbezzina Aug 8, 2026
d94920d
test(marquee): probe sub-phase 7g pins the overlay out of every ladder
angusbezzina Aug 8, 2026
2ea10f5
feat(overlay): marquee drag gesture with a tested click/frame threshold
angusbezzina Aug 8, 2026
09ac045
docs: README covers frame selection
angusbezzina Aug 8, 2026
2a0c40e
feat(marquee): draw a frame on the overlay to select (VRT-cne0.7)
angusbezzina Aug 8, 2026
5da04a4
feat(ios): marquee frame selection + exclude AnnotKit's overlay window
angusbezzina Aug 8, 2026
73d0102
feat(marquee): iOS port + parity (VRT-cne0.8)
angusbezzina Aug 8, 2026
66425a2
feat(overlay): explicit point/frame selection modes with a tool selector
angusbezzina Aug 8, 2026
ecc1706
feat(overlay): explicit point/frame selection modes (VRT-cne0.11)
angusbezzina Aug 8, 2026
9f963e4
feat(overlay): Select Parent / Select Child replace the one-way Widen…
angusbezzina Aug 9, 2026
45b31bb
feat(overlay): Select Parent / Select Child navigation (VRT-mijf.1)
angusbezzina Aug 9, 2026
862a1a0
feat(frame-mode): no hover highlight, and the drawn frame is the anchor
angusbezzina Aug 9, 2026
4554f49
feat(overlay): frame mode shows only the frame and anchors to it (VRT…
angusbezzina Aug 9, 2026
d7cc0eb
fix(marquee): reject a degenerate drag before touching any state
angusbezzina Aug 9, 2026
752e9b5
docs(navigation): record selection navigation + frame anchoring, cove…
angusbezzina Aug 9, 2026
26c3d4c
docs(marquee): DECISIONS/PARITY entries + probe Phase 8 selection nav…
angusbezzina Aug 9, 2026
fd9a00a
feat(icons): real elliptical arcs in the Lucide d-string parser
angusbezzina Aug 9, 2026
61151ea
feat(escape): back out one level at a time via a local key monitor
angusbezzina Aug 9, 2026
34d5964
feat(icons): real elliptical arcs in the Lucide path parser (VRT-dp47.3)
angusbezzina Aug 9, 2026
c6d1c39
feat(overlay): Escape backs out of AnnotKit one level at a time (VRT-…
angusbezzina Aug 9, 2026
516aa11
fix(overlay): re-arm the Escape monitor when re-mounting into an anno…
angusbezzina Aug 9, 2026
fc7c701
fix(overlay): clamp the pill to the visible screen and stop swallowin…
angusbezzina Aug 9, 2026
e158ca9
fix(overlay): keep the pill on screen and forward scroll to the host …
angusbezzina Aug 9, 2026
bb92fe3
feat(cards): one icon row per note card, shared with the pill
angusbezzina Aug 9, 2026
3cb5eee
feat(overlay): inline icon action row in both note cards (VRT-dp47.4)
angusbezzina Aug 9, 2026
8d3091b
fix(overlay): stop the hover-driven relayout storm that hid the pill
angusbezzina Aug 10, 2026
3267df7
fix(overlay): re-assert the clamped panel frame after AppKit's parent…
angusbezzina Aug 10, 2026
be624b4
feat(overlay): the toolbar gets its own window, so it can never move …
angusbezzina Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(marquee): session entry point + persisted frame on notes
`AnnotationSession.select(inAXRect:)` is the marquee counterpart to
`select(atAXPoint:)`. It delegates to `MarqueeTargetSource.marqueeLadder(in:)`,
whose ladder contract is identical to `ComponentLadderSource`'s, so widening and
the note's `component` field keep working untouched. A drag that resolves to
nothing degrades to a REGION note anchored at the frame's centre, mirroring the
no-hit click path but carrying the drawn frame instead of a point.

The drawn frame is held ABSOLUTE (`selectedMarqueeRect`) and relativized only at
`addNote`, because `widenSelection()` can rebind the note to an enclosing element
AFTER the drag — a frame relativized at selection time would describe a box the
note no longer names. Synthetic regions measure from `marqueeRegionOrigin` (the
anchor) since their own frame IS the drawn rect and would trivially be (0, 0).

Both new fields carry the exact stale-state hazard 7993a67 fixed for
`selectedRegionOffset`: the `selected` didSet only clears on nil, never on
replacement, so they are also cleared at the top of BOTH select entry points.
Tests cover the regression in both directions (marquee -> click must not leak
`regionRect`; region-click -> marquee must not leak `regionOffset`).

`AnnotationNote.regionRect` follows the additive-optional pattern of
`regionOffset` (doc comment, CodingKeys entry, trailing defaulted init param), so
old JSON decodes to nil and click notes serialize unchanged. The formatter emits
`**Region**: framed WxH at (x: X, y: Y) from the top-left of <selector>` as an
`else if` against the point-offset line — the two locators are mutually exclusive
by construction and the chain documents that.

86 -> 99 tests, all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
  • Loading branch information
angusbezzina and claude committed Aug 8, 2026
commit 5e99ca61a54be05356ed3419f02366236d0b3908
14 changes: 12 additions & 2 deletions Sources/AnnotKit/AnnotationSink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
/// the top-left of #Dashboard.Today"). Optional, so element notes serialize
/// unchanged and old files decode (nil).
public var regionOffset: CGPoint?
/// MARQUEE note: the frame the user DREW, with its origin relative to the
/// top-left of the element named by `selector` (its size is absolute). Like
/// `regionOffset` it is PERSISTED — it is the locator agents need when the
/// user framed a spot rather than clicked one ("a 320x48 band 40pt down inside
/// #Settings.Models"). Mutually exclusive with `regionOffset` by construction:
/// a note carries the point locator or the frame locator, never both. Optional,
/// so click notes serialize unchanged and old files decode (nil).
public var regionRect: CGRect?

/// Explicit keys that OMIT `anchor`: `JSONFileSink` and the MCP
/// `FileNotesStore` encode/decode `[AnnotationNote]` directly, so a naked
Expand All @@ -79,7 +87,7 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
private enum CodingKeys: String, CodingKey {
case id, route, selector, elementPath, selectedText, comment, screenshot, timestamp
case component, elementRole, elementText, unseeded
case regionOffset
case regionOffset, regionRect
}

public init(
Expand All @@ -96,7 +104,8 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
screenshot: CapturedImage? = nil,
timestamp: String,
anchor: CGPoint? = nil,
regionOffset: CGPoint? = nil
regionOffset: CGPoint? = nil,
regionRect: CGRect? = nil
) {
self.id = id
self.route = route
Expand All @@ -112,5 +121,6 @@ public struct AnnotationNote: Sendable, Hashable, Identifiable, Codable {
self.timestamp = timestamp
self.anchor = anchor
self.regionOffset = regionOffset
self.regionRect = regionRect
}
}
133 changes: 125 additions & 8 deletions Sources/AnnotKit/Overlay/AnnotationSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@ public final class AnnotationSession: ObservableObject {
@Published public private(set) var pending: [AnnotationNote] = []
@Published public private(set) var hovered: Element?
@Published public private(set) var selected: Element? {
// The region offset and the widening ladder only make sense while their
// selection is alive; clearing the selection (capture, cancel, stop, pin
// editing) must never leave a stale offset or ladder for the NEXT note.
// The region offset, the drawn marquee frame, and the widening ladder only
// make sense while their selection is alive; clearing the selection
// (capture, cancel, stop, pin editing) must never leave a stale offset,
// frame, or ladder for the NEXT note.
didSet {
if selected == nil {
selectedRegionOffset = nil
selectedMarqueeRect = nil
marqueeRegionOrigin = nil
componentLadder = []
ladderIndex = 0
}
Expand All @@ -38,6 +41,19 @@ public final class AnnotationSession: ObservableObject {
/// (see ``select(atAXPoint:)``); nil for ordinary element selections.
public private(set) var selectedRegionOffset: CGPoint?

/// The frame the user DREW for the current selection, in ABSOLUTE AX screen
/// coordinates; nil for click selections. Kept absolute (not element-relative)
/// so ``widenSelection()`` re-relativizes it for free: widening rebinds the
/// note to an enclosing element with a different origin, and a frame already
/// relativized at selection time would then silently describe the drag against
/// a box the note no longer names.
public private(set) var selectedMarqueeRect: CGRect?
/// Origin the drawn frame is measured from when the selection is a synthetic
/// REGION — whose own `frame` IS the drawn rect, so it cannot be its own
/// reference (it would relativize to 0,0 and locate nothing). nil for element
/// selections, which measure from the selected element's origin.
private var marqueeRegionOrigin: CGPoint?

/// The component-widening ladder for the current selection (target first, each
/// enclosing identified component after), and the index of the currently
/// selected rung. Populated on ``select(atAXPoint:)`` when the source offers a
Expand Down Expand Up @@ -122,11 +138,15 @@ public final class AnnotationSession: ObservableObject {
// Any catcher tap dismisses an open pin editor: a tap on empty space is a
// click-away close, and a tap on an element hands the stage to the composer.
editingNoteID = nil
// Every selection starts offset-free and ladder-free: a region -> element
// re-selection (the catcher stays active behind an open composer) must not
// leak the previous region's offset or ladder onto an ELEMENT note — the
// didSet only clears them when `selected` becomes nil, not on replacement.
// Every selection starts offset-free, frame-free and ladder-free: a
// region -> element or marquee -> click re-selection (the catcher stays
// active behind an open composer) must not leak the previous region's
// offset, the previous drag's drawn frame, or a stale ladder onto the next
// note — the didSet only clears them when `selected` becomes nil, not on
// replacement.
selectedRegionOffset = nil
selectedMarqueeRect = nil
marqueeRegionOrigin = nil
componentLadder = []
ladderIndex = 0
selected = source.hitTest(point)
Expand Down Expand Up @@ -160,6 +180,84 @@ public final class AnnotationSession: ObservableObject {
return selected
}

/// Select the annotation target for a frame the user DREW (AX top-left screen
/// coordinates) — the marquee gesture: press-drag a rectangle AROUND what you
/// mean instead of hunting for the one pixel that hit-tests to it.
///
/// The source resolves the frame (``MarqueeTargetSource``) and hands back the
/// same target-first, broadest-last ladder ``ComponentLadderSource`` produces,
/// so widening and the note's `component` field work unchanged. When nothing
/// in the frame is annotatable the drag is NOT dropped: it degrades to a
/// REGION note anchored at the frame's centre, exactly as a no-hit click does,
/// but carrying the drawn frame rather than a single point.
@discardableResult
public func select(inAXRect rect: CGRect) -> Element? {
guard mode == .annotating else { return nil }
// A drag on the catcher dismisses an open pin editor for the same reason a
// tap does: the composer is about to take the stage.
editingNoteID = nil
// Same stale-state hazard as the point path, and then some — a marquee ->
// click flow must not leak `selectedMarqueeRect` onto the click's note, and
// a region-click -> marquee flow must not leak `selectedRegionOffset` onto
// the framed one. The didSet only fires on nil, not on replacement.
selectedRegionOffset = nil
selectedMarqueeRect = nil
marqueeRegionOrigin = nil
componentLadder = []
ladderIndex = 0

// Normalize once: a right-to-left or bottom-to-top drag arrives with
// negative extents. A zero-width or zero-height rect is a click that never
// moved, not a marquee — bail before the region fallback too, or a stray
// press would silently plant a degenerate framed note.
let normalized = rect.standardized
guard normalized.width > 0, normalized.height > 0 else { return nil }

if let marqueeSource = source as? MarqueeTargetSource {
let ladder = marqueeSource.marqueeLadder(in: normalized)
if let {
selected = target
componentLadder = ladder
ladderIndex = 0
selectedMarqueeRect = normalized
return selected
}
}

// Region fallback, mirroring the point path but anchored at the frame's
// CENTRE — the one point inside a drawn rect that is nearest everything it
// covers, so the anchor a user would name themselves.
if let anchorSource = source as? RegionAnchorSource,
let anchorElement = anchorSource.regionAnchor(at: CGPoint(x: normalized.midX, y: normalized.midY)) {
let offset = CGPoint(
x: (normalized.minX - anchorElement.frame.minX).rounded(),
y: (normalized.minY - anchorElement.frame.minY).rounded()
)
let anchorName = anchorElement.label.isEmpty ? anchorElement.id : anchorElement.label
selected = Element(
id: anchorElement.id,
role: "AXRegion",
type: "Region",
label: "Region \(Int(normalized.width))x\(Int(normalized.height)) at (\(Int(offset.x)), \(Int(offset.y))) in \(anchorName)",
value: "",
// The DRAWN rect, not a marker at a point, so the overlay
// highlights the frame the user actually swept.
frame: normalized,
isVisible: true,
isActionable: false,
path: anchorElement.path
)
selectedMarqueeRect = normalized
// The synthetic element's frame IS the drawn rect, so it cannot be its
// own measuring stick; record the anchor's origin instead.
marqueeRegionOrigin = anchorElement.frame.origin
// Deliberately NOT setting `selectedRegionOffset`: `regionOffset` stays
// the point-click locator so every note carries exactly one, and the
// formatter's framed/offset branches stay mutually exclusive.
}
return selected
}

/// Step the current selection UP to the next enclosing identified component
/// (a coarser-grained note: the card instead of the label inside it). No-op
/// when the selection is already at the broadest rung, is a region, or the
Expand All @@ -171,6 +269,10 @@ public final class AnnotationSession: ObservableObject {
ladderIndex += 1
// Widening always lands on a real element, so it is never a region note.
selectedRegionOffset = nil
// `selectedMarqueeRect` is deliberately KEPT: it is absolute, so it is
// still the frame the user drew whichever rung is now bound, and `addNote`
// re-relativizes it against the widened element. `marqueeRegionOrigin` is
// moot here — regions get no ladder, so a widen can never start from one.
// A non-nil assignment does not trip the didSet clear, so the ladder and
// index survive for a further widen.
selected = componentLadder[ladderIndex]
Expand Down Expand Up @@ -206,6 +308,20 @@ public final class AnnotationSession: ObservableObject {
? ownIdentifier
: (componentLadder.dropFirst().first?.id
?? element.path.last(where: { !($0.identifier ?? "").isEmpty })?.identifier)
// Relativize the drawn frame HERE rather than at selection — the asymmetry
// with `regionOffset` (computed at selection) is deliberate:
// ``widenSelection()`` can rebind the note to an enclosing element AFTER
// the frame was drawn, so the persisted rect must be measured against
// whatever element the note FINALLY names. A region never widens, so its
// offset's anchor is fixed the moment it is picked.
let regionRect: CGRect? = selectedMarqueeRect.map { drawn in
// A synthetic region's own frame IS the drawn rect, so it measures from
// its anchor (else it would trivially be 0,0); elements measure from
// themselves.
let base = marqueeRegionOrigin ?? element.frame.origin
return CGRect(x: (drawn.minX - base.x).rounded(), y: (drawn.minY - base.y).rounded(),
width: drawn.width.rounded(), height: drawn.height.rounded())
}
let note = AnnotationNote(
id: makeID(),
route: route(),
Expand All @@ -220,7 +336,8 @@ public final class AnnotationSession: ObservableObject {
screenshot: screenshot,
timestamp: timestamp(),
anchor: anchor,
regionOffset: selectedRegionOffset
regionOffset: selectedRegionOffset,
regionRect: regionRect
)
pending.append(note)
selected = nil
Expand Down
17 changes: 16 additions & 1 deletion Sources/AnnotKit/Sinks/AnnotationFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ public enum AnnotationFormatter {
if note.unseeded == true {
lines.append("**Unseeded**: the clicked element has no accessibilityIdentifier — locate it via the Component above, then narrow by the Element role/text; consider seeding it")
}
if let region = note.regionOffset {
// `else if`, not a second `if`: the two locators are mutually exclusive by
// construction (a note is framed or it is a point, never both), and the
// chain is what documents that — two independent lines would let a future
// leak of one into the other print a self-contradicting block instead of
// failing loudly.
if let rect = note.regionRect {
lines.append("**Region**: framed \(Int(rect.width))x\(Int(rect.height)) at (x: \(Int(rect.minX)), y: \(Int(rect.minY))) from the top-left of \(note.selector)")
} else if let region = note.regionOffset {
lines.append("**Region**: (x: \(Int(region.x)), y: \(Int(region.y))) from the top-left of \(note.selector)")
}
if let selected = note.selectedText, !selected.isEmpty {
Expand Down Expand Up @@ -74,6 +81,10 @@ public enum AnnotationFormatter {
let timestamp: String
let regionOffsetX: Int?
let regionOffsetY: Int?
let regionRectX: Int?
let regionRectY: Int?
let regionRectWidth: Int?
let regionRectHeight: Int?
let screenshotPixelWidth: Int?
let screenshotPixelHeight: Int?

Expand All @@ -91,6 +102,10 @@ public enum AnnotationFormatter {
timestamp = note.timestamp
regionOffsetX = note.regionOffset.map { Int($0.x) }
regionOffsetY = note.regionOffset.map { Int($0.y) }
regionRectX = note.regionRect.map { Int($0.minX) }
regionRectY = note.regionRect.map { Int($0.minY) }
regionRectWidth = note.regionRect.map { Int($0.width) }
regionRectHeight = note.regionRect.map { Int($0.height) }
screenshotPixelWidth = note.screenshot?.pixelWidth
screenshotPixelHeight = note.screenshot?.pixelHeight
}
Expand Down
Loading