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(escape): back out one level at a time via a local key monitor
Escape previously did almost nothing: the only handler was `.onExitCommand`
on the shared card, which fires only when the overlay PANEL is key — and the
panel is made key solely by a card focusing its text field. In annotate mode
with nothing open (the state a user most wants to leave) the HOST window is
key, so no view in the panel ever saw the keystroke.

- `EscapeRule` (pure, platform-free, unit-tested): drag -> card -> mode, and
  pass-through when idle so the host's own Escape is untouched. Drag beats
  card because the catcher stays live behind an open composer, so the two
  coexist. `EscapeAction.consumesEvent` pins the swallow contract: AnnotKit
  is in-process with its host, so a handled-and-forwarded Escape would be
  acted on twice.
- `AnnotationSession` gains `isDrawingFrame` / `frameDragGeneration` /
  `hasOpenCard` so the handler, which lives outside the view, can see an
  in-flight gesture. Only the FLAG moves: the band's rect stays window-local
  `@State`, since everything in the session is AX screen space.
- `OverlayController` owns an `NSEvent` local key monitor, installed in
  `start()` and removed in BOTH `stop()` and `unmount()` — a leaked monitor
  would keep eating the host app's Escape for the life of the process.
- The composer draft is now cleared by the composer CLOSING rather than by
  the Cancel button, so a dismissal from anywhere no longer leaves a
  half-typed comment to reappear over the next selection. `.onExitCommand`
  is deleted, leaving Escape exactly one owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
  • Loading branch information
angusbezzina and claude committed Aug 9, 2026
commit 61151ea727db00e603ac64f14fdcd2d5518a1514
1 change: 1 addition & 0 deletions PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ row; each asymmetry is closed by code or has a tracked mitigation.
| Child navigation (select child) | `ChildNavigationSource` over the AX tree; the opt-in view-tree source implements it over `NSView` | `ChildNavigationSource` over the `UIView` tree | none — the ORDERING is the one pure `ChildNavigationRule` (contains the gesture's hint, then seeded, then larger area, then lowest index) on all three adapters, which supply only `[ChildCandidate]`. All three also share the cost shape the protocol mandates: re-find the bound element by descending its containing window along its own frame centre (bounded by tree DEPTH, not tree size), then collect the NEAREST meaningful descendants, each branch stopping at its first meaningful node — so a SwiftUI host's unidentified layout wrappers are descended THROUGH rather than offered as children, which is what keeps the Child control alive under pure SwiftUI on both platforms. macOS additionally skips window chrome and its own overlay window during that descent; UIKit has no chrome, and the overlay is already excluded by the shared window lookup |
| Frame-mode anchoring + hover gating | shared `AnnotationSession` (`selectionAnchorFrame`, the `tool == .point` hover gate, `setTool` clearing `hovered`) rendered by the shared `OverlayView` | same | none in the code — all of it is session-level and platform-free, and one SwiftUI view renders it. ASYMMETRIC VERIFICATION, recorded as a gap rather than closed: `AnnotKitOverlayProbe` Phase 8 drives navigation, the note's `component`, the hover gate and the frame anchor against a REAL accessibility tree, and it is macOS-only (`#if os(macOS)`, AppKit + `AXUIElement`), so the iOS adapter's live behaviour is covered only by unit tests over the pure rules. Mitigated, not fixed, by the fact that everything Phase 8 asserts about anchoring and hover lives in the shared session; what remains unverified on iOS is the ADAPTER's candidate collection. Note the hover gate is also moot on touch-only iOS — hover exists there only with a trackpad or pencil — so the reported symptom cannot arise without a pointer |
| Marquee drag threshold | cursor slop (a mouse does not move on a deliberate click) | larger touch slop | ASYMMETRIC BY DESIGN, owned by the drag UI, not the adapters: a finger rolls several points on a deliberate tap, so the macOS threshold on iOS would turn taps into marquees. Below the threshold both platforms route the gesture to the point path (`select(atAXPoint:)`), per the caller contract on `select(inAXRect:)` |
| Escape (back out one level) | `NSEvent.addLocalMonitorForEvents(matching: .keyDown)` owned by `OverlayController`, resolving the shared pure `EscapeRule` (drag → card → mode, pass-through when idle) | none — no Escape key exists on iOS | ASYMMETRIC BY THE HARDWARE, not by the code: a touch device has no Escape key, so there is nothing to bind. The DECISION is platform-free and unit-tested (`EscapeRule`), so an iOS back-out affordance (a swipe, a hardware-keyboard binding on iPad) can adopt it without re-deriving the precedence; only the macOS delivery mechanism is `#if os(macOS)`. The mechanism is a LOCAL KEY MONITOR rather than a SwiftUI modifier (`.onExitCommand`) because a panel-scoped modifier only fires while the overlay panel is KEY, and the panel is made key solely by a card focusing its text field — so in annotate mode with nothing open, the state a user most wants to leave, the HOST window is key and no view in the panel ever sees the keystroke. A local monitor works precisely because AnnotKit is in-process with its host: the Escape headed for the host window passes through it first, and it can swallow what it acted on (`EscapeAction.consumesEvent`), which a global monitor cannot. It is installed in `start()` and removed in BOTH `stop()` and `unmount()` — a monitor outliving the overlay would keep eating the host app's own Escape for the life of the process |
| Overlay excluded from element lookup | AX window identifier (`AXIntrospection.overlayWindowIdentifier`) filtered out of every `kAXWindows` read | `PassThroughWindow` TYPE identity filtered out of `IOSElementSource.windows()` | ASYMMETRIC BY NECESSITY — the hosts are different window kinds. macOS's overlay is a separate `NSPanel` matched by the identifier the controller stamps on it; iOS's is a `UIWindow` in the HOST's scene sharing its pid, so no pid/scene filter separates it and a type check (internal to the module) cannot drift the way an identifier convention can. Both filter in the shared window lookup, so snapshot / hit-test / region-anchor / marquee agree; leaving it in would let a marquee bind the user's note to AnnotKit's own UI |
| Coordinate space | Cocoa bottom-left to AX top-left flip | UIKit top-left native | iOS needs no flip; shared `ScreenSpace` used only on macOS |
| Screenshot | ScreenCaptureKit / `cacheDisplay` | `UIGraphicsImageRenderer` + `drawHierarchy` | both capture own hierarchy only; no cross-window or secure overlays |
Expand Down
60 changes: 60 additions & 0 deletions Sources/AnnotKit/Overlay/AnnotationSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,33 @@ public final class AnnotationSession: ObservableObject {
/// closes the other — so exactly one card is ever on screen.
@Published public private(set) var editingNoteID: String?

/// True while the catcher is drawing a frame. UI-only state, and deliberately
/// only the FLAG: the band's rect stays `@State` in the view because it is
/// WINDOW-LOCAL while everything else here is AX screen space, and a rect that
/// looks like the others but is measured from a different origin is the exact
/// confusion `OverlayView`'s header warns about (invisible on the primary
/// display, off by the window origin on every other one).
///
/// It lives here anyway because the Escape handler is OUTSIDE the view — a
/// process-wide key monitor owned by the host controller — and cannot otherwise
/// tell an in-flight gesture from an idle catcher.
@Published public private(set) var isDrawingFrame: Bool = false

/// Bumped when an in-flight frame drag is cancelled. The cancel signal has to be
/// an EDGE rather than a flag because AppKit delivers the gesture's `onEnded`
/// regardless — cancelling cannot suppress it — so the view compares the
/// generation it captured at drag start against this one on release and skips
/// resolution when they differ. A plain `isDrawingFrame == false` test would also
/// swallow the release of a NEW drag started in the same run, and a boolean
/// "wasCancelled" would need clearing by whoever read it last.
@Published public private(set) var frameDragGeneration: Int = 0

/// True when a composer or a pin editor is on screen. These two are mutually
/// exclusive by construction (``beginEditing(id:)`` nils `selected`; the `select`
/// paths nil `editingNoteID`), so this is "a card is up" — the single question
/// ``EscapeRule`` asks.
public var hasOpenCard: Bool { selected != nil || editingNoteID != nil }

private let source: ElementSource
private let sink: AnnotationSink
private let route: () -> String?
Expand Down Expand Up @@ -206,6 +233,13 @@ public final class AnnotationSession: ObservableObject {
selected = nil
// Leaving annotate mode hides the pins, so any open pin editor must go too.
editingNoteID = nil
// A drag in flight when the mode ends never receives its `onEnded`: the
// catcher is gated on annotate mode, so it is REMOVED from the view tree and
// SwiftUI drops the gesture silently. Without this, `isDrawingFrame` latches
// true into the next session — where Escape would "cancel" a drag nobody is
// making instead of exiting — and the drawn band, whose layer is not gated on
// mode, keeps painting over the idle overlay.
if isDrawingFrame { cancelFrameDrag() }
}

/// Update the hover highlight for a screen point (AX top-left coordinates).
Expand Down Expand Up @@ -233,6 +267,32 @@ public final class AnnotationSession: ObservableObject {
hovered = nil
}

/// The catcher's band just became visible (the press cleared the travel
/// threshold), so a frame drag is in flight.
public func beginFrameDrag() {
isDrawingFrame = true
}

/// The drag reached its natural end (the release). Deliberately does NOT bump the
/// generation: a normal release must still resolve into a selection, and the
/// generation is precisely the signal that says "don't".
public func endFrameDrag() {
isDrawingFrame = false
}

/// Abandon the frame being drawn. Bumping the generation is what actually
/// cancels: the release still arrives, and the view skips resolution because the
/// generation moved under it.
///
/// Touches NOTHING else — not `selected`, not `pending`. The catcher stays live
/// behind an open composer, so the drag being abandoned may have started while a
/// previous note was half-typed; clearing the selection here would make Escape
/// destroy a draft the user was not even interacting with.
public func cancelFrameDrag() {
frameDragGeneration &+= 1
isDrawingFrame = false
}

/// Select the element under a screen point (AX top-left coordinates).
///
/// When nothing resolves (decoration, dividers, gaps beyond any container's
Expand Down
58 changes: 58 additions & 0 deletions Sources/AnnotKit/Overlay/EscapeRule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/// What a press of Escape means, given only what is on screen.
///
/// Modelled as a value rather than handled inline in the key monitor for the same
/// reason ``SelectionGesture`` is: a decision buried in an AppKit event closure can
/// only be checked by a human with a keyboard, and this one is reachable from four
/// different UI states that a human tester will not think to enumerate.
public enum EscapeAction: Sendable, Hashable {
/// Throw away the frame the user is mid-way through drawing; the selection that
/// was live before the drag is untouched.
case cancelDrag
/// Close the open composer or pin editor, discarding its draft.
case dismissCard
/// Leave annotate mode entirely.
case exitAnnotateMode
/// AnnotKit has no claim on this keystroke; the host must see it.
case passThrough

/// Whether the key monitor should SWALLOW the event.
///
/// This is the monitor's whole contract with the host, and getting it wrong is
/// not cosmetic: AnnotKit runs IN-PROCESS with its host, so an Escape it acts on
/// and also forwards is delivered twice — the overlay leaves annotate mode while
/// the host closes its own sheet, from one press. Only ``passThrough`` (where
/// AnnotKit deliberately did nothing) may forward.
public var consumesEvent: Bool { self != .passThrough }
}

/// The pure Escape decision: given the three pieces of overlay state that a press
/// of Escape can plausibly refer to, name the ONE thing it backs out of.
///
/// The design is "undo one level at a time", and the ordering below is that policy.
/// A first Escape that exited annotate mode outright would take a half-typed comment
/// with it — the single irreversible thing in the flow, since a dismissed mode can be
/// re-entered with one click but a discarded draft cannot be retyped from anywhere.
public enum EscapeRule {
/// Resolve a press of Escape. Precedence is strict and the order is the whole
/// rule:
///
/// 1. **Not annotating → ``EscapeAction/passThrough``.** AnnotKit is not active,
/// so the host's own Escape handling — closing ITS sheets, dismissing ITS
/// menus — must be exactly as it was before AnnotKit was installed. An
/// overlay that ate Escape while idle would break the host app permanently
/// and look like the host's bug.
/// 2. **Drawing a frame → ``EscapeAction/cancelDrag``.** The in-flight gesture is
/// the most immediate thing on screen. Tested BEFORE the card rather than
/// after because the two genuinely COEXIST: the catcher stays live behind an
/// open composer, so a user can begin drawing a new frame while the previous
/// note's composer is still up. Card-first would then discard the draft the
/// user never touched and leave the band they are actively dragging on screen.
/// 3. **An open card → ``EscapeAction/dismissCard``.**
/// 4. **Otherwise → ``EscapeAction/exitAnnotateMode``.**
public static func resolve(isAnnotating: Bool, isDrawingFrame: Bool, hasOpenCard: Bool) -> EscapeAction {
guard isAnnotating else { return .passThrough }
if isDrawingFrame { return .cancelDrag }
if hasOpenCard { return .dismissCard }
return .exitAnnotateMode
}
}
Loading