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(overlay): Select Parent / Select Child replace the one-way Widen…
… button

Navigation is now bidirectional. Ascending is the old widenSelection();
descending prefers HISTORY (walk back down the path you climbed) and only
queries the source for children when already at the deepest known rung,
prepending the child so a re-ascent returns to the target and a re-descent
replays to the same child rather than re-running the heuristic under a
live UI.

Fixes a silent corruption prepending would otherwise introduce: the note's
component was path[1], which held only while the path was strictly upward
(every upward rung is seeded). With a child at index 0, index 1 is the
original target, which may be unseeded -- and an unseeded Element.id is a
slash-joined path, so the note would have exported a grep target matching
nothing while looking plausible. It now skips to the first SEEDED rung
above the bound one. Pinned by a mutation-verified test.

Adds ChildNavigationSource with a shared pure ordering rule, implemented on
both macOS sources and iOS. canSelectChild reads a cache, never the source,
so SwiftUI rendering cannot trigger an AX walk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
  • Loading branch information
angusbezzina and claude committed Aug 9, 2026
commit 9f963e41df3ca523b2b698191d5fd04678f5f4f9
85 changes: 85 additions & 0 deletions Sources/AnnotKit/ChildNavigationRule.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import CoreGraphics
import Foundation

/// One element considered as a CHILD of the currently-bound target: its
/// annotation-relevant facts and its frame in the source's screen space (AX
/// top-left on macOS, view-local on iOS — the same space the hint arrives in, so
/// containment is a plain rect test with no conversion).
///
/// The platform adapter reads these facts once — from the AX tree on macOS, from
/// the view tree on either platform — and hands the rule a flat array, so nothing
/// here is a live platform handle and the ordering is unit-tested without a
/// running app. Deliberately NOT ``MarqueeCandidate``: depth is meaningless for a
/// single generation of siblings, and reusing a struct with a field the rule must
/// ignore invites a future tie-break reading it.
public struct ChildCandidate: Sendable, Hashable {
public let element: TargetCandidate
public let frame: CGRect

public init(element: TargetCandidate, frame: CGRect) {
self.element = element
self.frame = frame
}

/// Seeded = carries an `accessibilityIdentifier`. The identifier is what
/// locates source code, so at the same geometric standing it decides whether
/// descending produces a note an agent can act on.
fileprivate var isSeeded: Bool { !element.identifier.isEmpty }

fileprivate var area: CGFloat { frame.width * frame.height }
}

/// The child-navigation ordering rule: given every child the adapter could see
/// under the bound element, decide which one "Select Child" should descend into.
/// Pure and platform-independent, the downward counterpart of
/// ``AnnotationTargetRule`` — all three adapters (macOS AX, macOS view-tree, iOS)
/// build `[ChildCandidate]` from their own node types and call ``order(_:near:)``,
/// so a descent resolves identically everywhere and the decision is tested on its
/// own rather than by three hand-rolled sorts kept in step by hand.
public enum ChildNavigationRule {
/// Indices into `candidates`, most-likely-intended FIRST; ineligible entries
/// are dropped entirely, so an empty result means "leaf, nothing to descend
/// into".
///
/// Ranking, in order:
///
/// 1. **Contains the hint.** `hint` is the gesture's own anchor — the point
/// clicked, or the centre of the frame drawn. When one child sits under it
/// that child is the one the user was already pointing at, which beats any
/// guess made from geometry alone.
/// 2. **Seeded beats unseeded.** Descending into an unseeded child yields a
/// note whose `component` has to be inherited from an ancestor; descending
/// into a seeded one names a component directly.
/// 3. **Larger area.** Going DOWN one level should land on the substantial
/// thing inside (the card's content row), not the 8pt chevron that happens
/// to be its first sibling — the same "I mean this whole thing" instinct
/// ``MarqueeTargetRule``'s surrounded pass encodes, applied one level down.
/// 4. **Earlier index**, so the same selection always descends the same way.
/// `sorted(by:)` is not stable, so this is an explicit key rather than an
/// assumption: without it two coextensive children would swap places
/// between runs and repeating a descent could land somewhere new.
///
/// Eligibility is ``TargetCandidate/isEligibleMeaningful``, the SAME predicate
/// the target rule uses, so window chrome, container roots, and window-ghost
/// groups can never be offered as children — descending into any of them binds
/// the note to something that names no app code.
public static func order(_ candidates: [ChildCandidate], near hint: CGPoint?) -> [Int] {
candidates.indices
.filter { i in
// Positive area twice over: a zero-area child is nothing the user
// could have meant, and it would otherwise sort last-but-present
// and be offered as a descent target on a leaf.
candidates[i].element.isEligibleMeaningful
&& candidates[i].frame.width > 0 && candidates[i].frame.height > 0
}
.sorted { lhs, rhs in
let (l, r) = (candidates[lhs], candidates[rhs])
let lHit = hint.map(l.frame.contains) ?? false
let rHit = hint.map(r.frame.contains) ?? false
if lHit != rHit { return lHit }
if l.isSeeded != r.isSeeded { return l.isSeeded }
if l.area != r.area { return l.area > r.area }
return lhs < rhs
}
}
}
27 changes: 24 additions & 3 deletions Sources/AnnotKit/ElementSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,35 @@ public protocol RegionAnchorSource {
/// the annotation target first (same element ``ElementSource/hitTest(_:)``
/// returns), then each enclosing identified component, broadest last. Lets the
/// session step a selection UP to a parent component for a coarser-grained note
/// (a click-again / widen affordance while the composer is open) without
/// re-hit-testing. Sources that cannot offer it simply don't conform; the session
/// then disables widening.
/// (the composer's Select Parent control) without re-hit-testing. Sources that
/// cannot offer it simply don't conform; the session then has a single-rung
/// selection path and Select Parent stays disabled.
@MainActor
public protocol ComponentLadderSource {
func componentLadder(at point: CGPoint) -> [Element]
}

/// Optional element-source capability: the meaningful children of an element —
/// the DOWNWARD counterpart of ``ComponentLadderSource``. Lets the session step a
/// selection INTO a component (the row inside the card, rather than the card) when
/// the user overshot, or when the target rule bound coarser than they meant.
/// Sources that cannot offer it simply don't conform; the session then leaves the
/// composer's Child control permanently disabled, and only upward navigation (and
/// downward navigation back through already-visited rungs) is available.
@MainActor
public protocol ChildNavigationSource {
/// Meaningful children of `element`, most-likely-intended FIRST. `hint` is the
/// gesture's anchor (the click point, or the drawn frame's centre) when there
/// is one. Empty when the element is a leaf.
///
/// Ordering is the shared, pure ``ChildNavigationRule`` on every adapter, so
/// the same layout descends the same way on macOS and iOS. Implementations
/// MUST NOT snapshot the whole tree per call: this runs on every selection
/// change, and the bound element's frame centre gives a cheap descent path from
/// the containing window (the same trick the hit-test uses).
func children(of element: Element, near hint: CGPoint?) -> [Element]
}

/// Optional element-source capability: resolve a frame the user DREW (a marquee
/// press-drag) to an annotation target, per ``MarqueeTargetRule`` — the largest
/// meaningful element the frame surrounds, else the tightest element the frame
Expand Down
Loading