things-api 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +57 -12
- package/dist/audit/log.js +56 -2
- package/dist/audit/log.js.map +1 -1
- package/dist/audit/schema.d.ts +26 -8
- package/dist/audit/schema.js +42 -1
- package/dist/audit/schema.js.map +1 -1
- package/dist/cli/clock.d.ts +8 -0
- package/dist/cli/clock.js +24 -0
- package/dist/cli/clock.js.map +1 -0
- package/dist/cli/commands/area.d.ts +26 -3
- package/dist/cli/commands/area.js +129 -75
- package/dist/cli/commands/area.js.map +1 -1
- package/dist/cli/commands/doctor.d.ts +1 -1
- package/dist/cli/commands/doctor.js +44 -5
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/install-skill.d.ts +45 -0
- package/dist/cli/commands/install-skill.js +207 -0
- package/dist/cli/commands/install-skill.js.map +1 -0
- package/dist/cli/commands/mcp.js +23 -4
- package/dist/cli/commands/mcp.js.map +1 -1
- package/dist/cli/commands/project.d.ts +18 -1
- package/dist/cli/commands/project.js +74 -35
- package/dist/cli/commands/project.js.map +1 -1
- package/dist/cli/commands/reads.d.ts +15 -2
- package/dist/cli/commands/reads.js +459 -165
- package/dist/cli/commands/reads.js.map +1 -1
- package/dist/cli/commands/repeat-flags.d.ts +20 -0
- package/dist/cli/commands/repeat-flags.js +76 -0
- package/dist/cli/commands/repeat-flags.js.map +1 -0
- package/dist/cli/commands/setup.js +8 -4
- package/dist/cli/commands/setup.js.map +1 -1
- package/dist/cli/commands/show.js +53 -30
- package/dist/cli/commands/show.js.map +1 -1
- package/dist/cli/commands/todo.d.ts +1 -1
- package/dist/cli/commands/todo.js +20 -13
- package/dist/cli/commands/todo.js.map +1 -1
- package/dist/cli/commands/writes.js +388 -161
- package/dist/cli/commands/writes.js.map +1 -1
- package/dist/cli/did-you-mean.d.ts +1 -1
- package/dist/cli/excess-args.d.ts +15 -0
- package/dist/cli/excess-args.js +51 -0
- package/dist/cli/excess-args.js.map +1 -0
- package/dist/cli/glyphs.d.ts +55 -2
- package/dist/cli/glyphs.js +114 -21
- package/dist/cli/glyphs.js.map +1 -1
- package/dist/cli/help.d.ts +54 -0
- package/dist/cli/help.js +405 -0
- package/dist/cli/help.js.map +1 -0
- package/dist/cli/main.js +64 -26
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/move-hint.d.ts +45 -0
- package/dist/cli/move-hint.js +196 -0
- package/dist/cli/move-hint.js.map +1 -0
- package/dist/cli/period.d.ts +9 -8
- package/dist/cli/period.js +56 -30
- package/dist/cli/period.js.map +1 -1
- package/dist/cli/read-driver.d.ts +26 -19
- package/dist/cli/read-driver.js +77 -33
- package/dist/cli/read-driver.js.map +1 -1
- package/dist/cli/render.d.ts +92 -28
- package/dist/cli/render.js +333 -103
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/resolve-invocation.d.ts +42 -4
- package/dist/cli/resolve-invocation.js +97 -14
- package/dist/cli/resolve-invocation.js.map +1 -1
- package/dist/cli/skill-check.d.ts +21 -0
- package/dist/cli/skill-check.js +82 -0
- package/dist/cli/skill-check.js.map +1 -0
- package/dist/cli/skill.d.ts +62 -0
- package/dist/cli/skill.js +142 -0
- package/dist/cli/skill.js.map +1 -0
- package/dist/cli/tag-filters.d.ts +63 -0
- package/dist/cli/tag-filters.js +59 -0
- package/dist/cli/tag-filters.js.map +1 -0
- package/dist/cli/verb-hint.d.ts +25 -0
- package/dist/cli/verb-hint.js +163 -0
- package/dist/cli/verb-hint.js.map +1 -0
- package/dist/cli/version.d.ts +4 -0
- package/dist/cli/version.js +18 -0
- package/dist/cli/version.js.map +1 -0
- package/dist/cli/width.d.ts +135 -0
- package/dist/cli/width.js +313 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/client.d.ts +160 -24
- package/dist/client.js +132 -19
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +11 -0
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/contracts.d.ts +97 -17
- package/dist/contracts.js +44 -1
- package/dist/contracts.js.map +1 -1
- package/dist/db/fingerprint.d.ts +12 -0
- package/dist/db/fingerprint.js +15 -1
- package/dist/db/fingerprint.js.map +1 -1
- package/dist/db/locate.js +1 -1
- package/dist/db/locate.js.map +1 -1
- package/dist/diagnose.d.ts +63 -0
- package/dist/diagnose.js +38 -1
- package/dist/diagnose.js.map +1 -1
- package/dist/index.d.ts +36 -5
- package/dist/index.js +38 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/server.d.ts +9 -1
- package/dist/mcp/server.js +1038 -436
- package/dist/mcp/server.js.map +1 -1
- package/dist/model/clock.d.ts +61 -0
- package/dist/model/clock.js +71 -0
- package/dist/model/clock.js.map +1 -0
- package/dist/model/dates.d.ts +26 -3
- package/dist/model/dates.js +84 -3
- package/dist/model/dates.js.map +1 -1
- package/dist/model/entities.d.ts +48 -10
- package/dist/model/entities.js.map +1 -1
- package/dist/model/mappers.d.ts +9 -2
- package/dist/model/mappers.js +32 -9
- package/dist/model/mappers.js.map +1 -1
- package/dist/model/serialize.d.ts +28 -0
- package/dist/model/serialize.js +80 -0
- package/dist/model/serialize.js.map +1 -0
- package/dist/model/when-sugar.d.ts +47 -0
- package/dist/model/when-sugar.js +45 -0
- package/dist/model/when-sugar.js.map +1 -0
- package/dist/read/area-view.d.ts +8 -1
- package/dist/read/area-view.js +55 -17
- package/dist/read/area-view.js.map +1 -1
- package/dist/read/detail.d.ts +1 -1
- package/dist/read/detail.js +11 -4
- package/dist/read/detail.js.map +1 -1
- package/dist/read/filter-contract.d.ts +117 -0
- package/dist/read/filter-contract.js +78 -0
- package/dist/read/filter-contract.js.map +1 -0
- package/dist/read/log-boundary.d.ts +1 -1
- package/dist/read/log-boundary.js +13 -3
- package/dist/read/log-boundary.js.map +1 -1
- package/dist/read/predicates.d.ts +30 -0
- package/dist/read/predicates.js +30 -0
- package/dist/read/predicates.js.map +1 -1
- package/dist/read/project-view.d.ts +12 -1
- package/dist/read/project-view.js +45 -12
- package/dist/read/project-view.js.map +1 -1
- package/dist/read/queries.d.ts +152 -7
- package/dist/read/queries.js +277 -36
- package/dist/read/queries.js.map +1 -1
- package/dist/read/sections.d.ts +51 -0
- package/dist/read/sections.js +37 -0
- package/dist/read/sections.js.map +1 -0
- package/dist/read/sidebar-order.js +2 -1
- package/dist/read/sidebar-order.js.map +1 -1
- package/dist/read/snapshot.d.ts +1 -1
- package/dist/read/snapshot.js +9 -2
- package/dist/read/snapshot.js.map +1 -1
- package/dist/read/tags.d.ts +27 -3
- package/dist/read/tags.js +83 -12
- package/dist/read/tags.js.map +1 -1
- package/dist/read/truncation.d.ts +71 -0
- package/dist/read/{pagination.js → truncation.js} +103 -94
- package/dist/read/truncation.js.map +1 -0
- package/dist/read/views.d.ts +147 -22
- package/dist/read/views.js +282 -80
- package/dist/read/views.js.map +1 -1
- package/dist/surface-copy.d.ts +19 -0
- package/dist/surface-copy.js +32 -0
- package/dist/surface-copy.js.map +1 -1
- package/dist/sync-health.d.ts +78 -0
- package/dist/sync-health.js +312 -0
- package/dist/sync-health.js.map +1 -0
- package/dist/write/accessibility-probe.d.ts +12 -0
- package/dist/write/accessibility-probe.js +71 -0
- package/dist/write/accessibility-probe.js.map +1 -0
- package/dist/write/automation-probe.d.ts +8 -1
- package/dist/write/automation-probe.js +16 -1
- package/dist/write/automation-probe.js.map +1 -1
- package/dist/write/batch.js +5 -2
- package/dist/write/batch.js.map +1 -1
- package/dist/write/capabilities.d.ts +8 -0
- package/dist/write/capabilities.js +13 -6
- package/dist/write/capabilities.js.map +1 -1
- package/dist/write/commands.d.ts +3 -1
- package/dist/write/commands.js +602 -49
- package/dist/write/commands.js.map +1 -1
- package/dist/write/edit-checklist.js +3 -2
- package/dist/write/edit-checklist.js.map +1 -1
- package/dist/write/guards.d.ts +2 -7
- package/dist/write/guards.js +87 -6
- package/dist/write/guards.js.map +1 -1
- package/dist/write/heading.js +2 -0
- package/dist/write/heading.js.map +1 -1
- package/dist/write/lock.d.ts +40 -2
- package/dist/write/lock.js +91 -14
- package/dist/write/lock.js.map +1 -1
- package/dist/write/make-repeating-project.d.ts +4 -0
- package/dist/write/make-repeating-project.js +253 -0
- package/dist/write/make-repeating-project.js.map +1 -0
- package/dist/write/operations.d.ts +145 -1
- package/dist/write/operations.js +48 -0
- package/dist/write/operations.js.map +1 -1
- package/dist/write/pipeline.d.ts +73 -2
- package/dist/write/pipeline.js +218 -43
- package/dist/write/pipeline.js.map +1 -1
- package/dist/write/planner.js +8 -1
- package/dist/write/planner.js.map +1 -1
- package/dist/write/pre-state.d.ts +105 -3
- package/dist/write/pre-state.js +144 -3
- package/dist/write/pre-state.js.map +1 -1
- package/dist/write/recurrence-rule-blob.d.ts +65 -0
- package/dist/write/recurrence-rule-blob.js +150 -0
- package/dist/write/recurrence-rule-blob.js.map +1 -0
- package/dist/write/reopen.js +3 -2
- package/dist/write/reopen.js.map +1 -1
- package/dist/write/reorder.js +27 -17
- package/dist/write/reorder.js.map +1 -1
- package/dist/write/repeat-rule.d.ts +22 -0
- package/dist/write/repeat-rule.js +258 -0
- package/dist/write/repeat-rule.js.map +1 -0
- package/dist/write/reversibility.js +49 -0
- package/dist/write/reversibility.js.map +1 -1
- package/dist/write/tag-refs.d.ts +47 -0
- package/dist/write/tag-refs.js +126 -0
- package/dist/write/tag-refs.js.map +1 -0
- package/dist/write/undo.d.ts +74 -5
- package/dist/write/undo.js +493 -80
- package/dist/write/undo.js.map +1 -1
- package/dist/write/vectors/registry.d.ts +15 -1
- package/dist/write/vectors/registry.js +65 -2
- package/dist/write/vectors/registry.js.map +1 -1
- package/dist/write/vectors/simulator.d.ts +44 -0
- package/dist/write/vectors/simulator.js +1136 -0
- package/dist/write/vectors/simulator.js.map +1 -0
- package/dist/write/vectors/types.d.ts +154 -3
- package/dist/write/vectors/ui-certification.d.ts +54 -0
- package/dist/write/vectors/ui-certification.js +43 -0
- package/dist/write/vectors/ui-certification.js.map +1 -0
- package/dist/write/vectors/ui-drag.d.ts +188 -0
- package/dist/write/vectors/ui-drag.js +1095 -0
- package/dist/write/vectors/ui-drag.js.map +1 -0
- package/dist/write/vectors/ui-recipes.d.ts +83 -0
- package/dist/write/vectors/ui-recipes.js +610 -0
- package/dist/write/vectors/ui-recipes.js.map +1 -0
- package/dist/write/vectors/ui.d.ts +152 -0
- package/dist/write/vectors/ui.js +704 -0
- package/dist/write/vectors/ui.js.map +1 -0
- package/dist/write/verify/delta.d.ts +141 -4
- package/dist/write/verify/delta.js +199 -21
- package/dist/write/verify/delta.js.map +1 -1
- package/dist/write/verify/poller.d.ts +7 -1
- package/dist/write/verify/poller.js +21 -1
- package/dist/write/verify/poller.js.map +1 -1
- package/package.json +18 -3
- package/skills/things-cli/SKILL.md +53 -0
- package/skills/things-cli/references/contracts.md +41 -0
- package/skills/things-cli/references/gui.md +11 -0
- package/skills/things-cli/references/model.md +51 -0
- package/dist/cli/exit-codes.d.ts +0 -26
- package/dist/cli/exit-codes.js +0 -26
- package/dist/cli/exit-codes.js.map +0 -1
- package/dist/cli/output.d.ts +0 -42
- package/dist/cli/output.js +0 -16
- package/dist/cli/output.js.map +0 -1
- package/dist/read/pagination.d.ts +0 -104
- package/dist/read/pagination.js.map +0 -1
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar AREA drag-reorder driver for the ui vector (the AXDRAG1/AXDRAG2 lab
|
|
3
|
+
* campaigns). Moves an area to a new sidebar rank by synthesizing a real mouse
|
|
4
|
+
* drag through the NATIVE1 JXA ObjC-bridge / HID-tap path.
|
|
5
|
+
*
|
|
6
|
+
* Doctrine (docs/design/ui-vector.md + ax-initiative.md standing constraints):
|
|
7
|
+
* every click/drag anchor derives from AX-RESOLVED element frames read from the
|
|
8
|
+
* live tree immediately before the gesture — never a guessed pixel. Slot-
|
|
9
|
+
* boundary offsets computed FROM resolved frames are geometry, not guessing.
|
|
10
|
+
* Scroll-wheel synthesis is positionless and allowed. Fail-closed: an
|
|
11
|
+
* unresolvable sidebar/row refuses BEFORE any synthesis; every hop is followed
|
|
12
|
+
* by a database assert (order progressed as aimed; the area count and every
|
|
13
|
+
* to-do/project area assignment are unchanged), and an assert failure triggers
|
|
14
|
+
* one verified recovery drag back to the pre-op position before the op errors.
|
|
15
|
+
*
|
|
16
|
+
* The visibility LADDER (design rulings 2026-07-15, amended: edge-hover
|
|
17
|
+
* auto-scroll is REJECTED for production — app-controlled scroll velocity is
|
|
18
|
+
* too brittle; AXDRAG1-c stays lab evidence only):
|
|
19
|
+
* - Rung 1 (common case): pre-scroll until the source row and the drop
|
|
20
|
+
* boundary are simultaneously visible, then one certified AXDRAG1 drag.
|
|
21
|
+
* - Rung 2 (scroll-while-held): grab the source, synthesize SCROLL-WHEEL
|
|
22
|
+
* events while the drag is held so the list scrolls underneath the held
|
|
23
|
+
* item, re-resolve the target row's live frame, drop at the computed slot
|
|
24
|
+
* boundary. Gated on the AXDRAG2-a probe (mid-drag AX frame resolution AND
|
|
25
|
+
* mid-drag wheel delivery); not shipped unless both halves pass.
|
|
26
|
+
* - Rung 3 (multi-hop fallback, the correctness floor): move the area one
|
|
27
|
+
* viewport per hop — drop at the furthest visible slot toward the target,
|
|
28
|
+
* re-scroll, re-grab, repeat. The DB is asserted after EVERY hop, and an
|
|
29
|
+
* INFINITE-LOOP GUARD enforces termination: each hop must STRICTLY reduce
|
|
30
|
+
* the remaining distance to the target rank (one retry allowed, then the
|
|
31
|
+
* op aborts reporting where the area ended up — a partially-moved area is
|
|
32
|
+
* benign), under an absolute hop cap of ceil(areas / visible-slots) + 2.
|
|
33
|
+
*
|
|
34
|
+
* Load-bearing AXDRAG1 geometry — with NO hardcoded pixel geometry (design
|
|
35
|
+
* amendment 2026-07-15): every aimed coordinate and distance is derived at
|
|
36
|
+
* runtime from the live AX-resolved frames of the SAME snapshot generation
|
|
37
|
+
* (the lab observed 24px rows / 16px spacers / 40px slots, but the driver
|
|
38
|
+
* never assumes those numbers, so a text-size change only rescales it):
|
|
39
|
+
* - The drop boundary that inserts ABOVE a row is the midpoint of the region
|
|
40
|
+
* between it and the row above it — the spacer row's center when a spacer
|
|
41
|
+
* row sits between entity rows, the shared edge otherwise.
|
|
42
|
+
* - Slot pitch, where an estimate is needed (hop-cap sizing, span fallback),
|
|
43
|
+
* is the median adjacent y-delta of the resolved area rows.
|
|
44
|
+
* - Lifting the source collapses its slot (its whole group — nested project
|
|
45
|
+
* rows travel with it), so for DOWNWARD drags every static coordinate below
|
|
46
|
+
* the source shifts up by the source's group span. The span is computed
|
|
47
|
+
* from resolved frames (next area row top − source row top), so areas with
|
|
48
|
+
* visible nested projects stay correct.
|
|
49
|
+
* - Off-viewport rows still expose valid virtualized frames; visibility must
|
|
50
|
+
* be cross-checked against the scroll-area viewport rect.
|
|
51
|
+
* - Row identity: the AppleScript-seeded sidebar rows carry an EMPTY
|
|
52
|
+
* AXDescription — areas are identified by descendant AXStaticText segments.
|
|
53
|
+
* - Index semantics: TMArea."index" ascending == sidebar order; a drag may
|
|
54
|
+
* renumber a NEIGHBOR rather than the dragged row, so all asserts compare
|
|
55
|
+
* RELATIVE positions, never index values.
|
|
56
|
+
*/
|
|
57
|
+
import { createHash } from "node:crypto";
|
|
58
|
+
/** The client-side default aux: reads area order + assignments from the DB. */
|
|
59
|
+
export function createUiDriveAux(db) {
|
|
60
|
+
return {
|
|
61
|
+
areaState() {
|
|
62
|
+
const areas = db
|
|
63
|
+
.prepare(`SELECT uuid, title, "index" AS idx FROM TMArea ORDER BY "index", uuid`)
|
|
64
|
+
.all();
|
|
65
|
+
const assignments = db
|
|
66
|
+
.prepare("SELECT uuid, COALESCE(area, '') AS a FROM TMTask WHERE trashed = 0 ORDER BY uuid")
|
|
67
|
+
.all();
|
|
68
|
+
const hash = createHash("sha256");
|
|
69
|
+
for (const row of assignments)
|
|
70
|
+
hash.update(`${row.uuid}:${row.a}\n`);
|
|
71
|
+
return {
|
|
72
|
+
areas: areas.map((a) => ({ uuid: a.uuid, title: a.title, index: a.idx })),
|
|
73
|
+
assignmentsDigest: hash.digest("hex"),
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
// -------------------------------------------------- JXA command shapes
|
|
79
|
+
// One stable script shape per primitive (NATIVE1/AXDRAG1 incantations,
|
|
80
|
+
// verbatim where they are load-bearing). All dispatch through the injectable
|
|
81
|
+
// UiRunner seam, so ladder logic is unit-testable without a GUI.
|
|
82
|
+
const JXA_PRELUDE = `ObjC.import('AppKit');
|
|
83
|
+
ObjC.import('ApplicationServices');
|
|
84
|
+
ObjC.import('CoreGraphics');
|
|
85
|
+
function pidOf(n){ return Application('System Events').processes.byName(n).unixId() }
|
|
86
|
+
function sleep(ms){ $.NSThread.sleepForTimeInterval(ms/1000) }
|
|
87
|
+
function attr(el,name){ var out=Ref(); if($.AXUIElementCopyAttributeValue(el,$(name),out)!==0) return null; return ObjC.castRefToObject(out[0]) }
|
|
88
|
+
function sv(el,name){ var v=attr(el,name); return v? v.js : '' }
|
|
89
|
+
function frame(el){ var p=attr(el,'AXPosition'), z=attr(el,'AXSize'); if(!p||!z) return null;
|
|
90
|
+
var pd=ObjC.castRefToObject($.CFCopyDescription(p)).js, zd=ObjC.castRefToObject($.CFCopyDescription(z)).js;
|
|
91
|
+
var pm=pd.match(/x:([-0-9.]+) y:([-0-9.]+)/), zm=zd.match(/w:([-0-9.]+) h:([-0-9.]+)/);
|
|
92
|
+
return (pm&&zm)?{x:+pm[1],y:+pm[2],w:+zm[1],h:+zm[2]}:null }
|
|
93
|
+
function kids(el){ var c=attr(el,'AXChildren'); if(!c) return []; var a=[]; for(var i=0;i<c.count;i++) a.push(c.objectAtIndex(i)); return a }
|
|
94
|
+
function findAll(el, wantRole, depth, acc){ acc=acc||[]; if(depth<0) return acc; var ch=kids(el);
|
|
95
|
+
for(var i=0;i<ch.length;i++){ if(sv(ch[i],'AXRole')===wantRole) acc.push(ch[i]); findAll(ch[i], wantRole, depth-1, acc) } return acc }
|
|
96
|
+
function appEl(){ return $.AXUIElementCreateApplication(pidOf('Things3')) }
|
|
97
|
+
function stdWindow(){ var ws=kids(appEl()); for(var i=0;i<ws.length;i++){ if(sv(ws[i],'AXSubrole')==='AXStandardWindow') return ws[i] } return ws.length?ws[0]:null }
|
|
98
|
+
function sidebarTable(){ var w=stdWindow(); if(!w) return null; var tables=findAll(w,'AXTable',12,[]); var best=null;
|
|
99
|
+
for(var i=0;i<tables.length;i++){ var f=frame(tables[i]); if(!f) continue; if(f.w<400){ if(!best||f.w<best.f.w) best={el:tables[i],f:f} } }
|
|
100
|
+
return best?best.el:null }
|
|
101
|
+
var MOVED=5, DOWN=1, UP=2, DRAG=6;
|
|
102
|
+
function mev(t,x,y,cs){ var e=$.CGEventCreateMouseEvent($(), t, $.CGPointMake(x,y), 0); if(cs) $.CGEventSetIntegerValueField(e,1,cs); return e }
|
|
103
|
+
function postHID(ev){ $.CGEventPost($.kCGHIDEventTap, ev) }`;
|
|
104
|
+
/** Snapshot: sidebar rows (text + frames), viewport rect, scroll fraction. */
|
|
105
|
+
export function jxaSidebarSnapshotScript() {
|
|
106
|
+
return `${JXA_PRELUDE}
|
|
107
|
+
function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
|
|
108
|
+
var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
|
|
109
|
+
var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
|
|
110
|
+
var t = sidebarTable();
|
|
111
|
+
var out = { viewport: null, scroll: null, rows: [] };
|
|
112
|
+
if (t !== null) {
|
|
113
|
+
var w = stdWindow();
|
|
114
|
+
var areas = findAll(w,'AXScrollArea',12,[]);
|
|
115
|
+
for (var i=0;i<areas.length;i++){ var f=frame(areas[i]); if(f && f.w<400){ out.viewport=f;
|
|
116
|
+
var bars=findAll(areas[i],'AXScrollBar',4,[]);
|
|
117
|
+
for (var b=0;b<bars.length;b++){ var v=attr(bars[b],'AXValue'); if(v===null) continue;
|
|
118
|
+
var d=ObjC.castRefToObject($.CFCopyDescription(v)).js; var m=d.match(/value = ([+\\-0-9.]+)/);
|
|
119
|
+
if(m){ out.scroll = +m[1]; break } }
|
|
120
|
+
break } }
|
|
121
|
+
var ch = kids(t);
|
|
122
|
+
for (var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
|
|
123
|
+
if (role==='AXRow'||role==='AXTableRow'){ var rf=frame(ch[r]);
|
|
124
|
+
out.rows.push({ text: allText(ch[r],[],6).join('|'), x: rf?rf.x:null, y: rf?rf.y:null, w: rf?rf.w:null, h: rf?rf.h:null }) } }
|
|
125
|
+
}
|
|
126
|
+
JSON.stringify(out)`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Scroll: move the pointer over the sidebar center (wheel events target the
|
|
130
|
+
* surface under the cursor), then post `clicks` line-unit wheel events.
|
|
131
|
+
* Positive clicks move the CONTENT down (earlier rows return, row y grows);
|
|
132
|
+
* negative clicks reveal lower rows (row y shrinks) — AXDRAG1-b.
|
|
133
|
+
*/
|
|
134
|
+
export function jxaSidebarScrollScript(clicks) {
|
|
135
|
+
const n = Math.trunc(clicks);
|
|
136
|
+
return `${JXA_PRELUDE}
|
|
137
|
+
var w = stdWindow();
|
|
138
|
+
var sb = null;
|
|
139
|
+
if (w !== null) { var sas = findAll(w,'AXScrollArea',12,[]);
|
|
140
|
+
for (var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400){ sb=f; break } } }
|
|
141
|
+
if (sb === null) { 'NO_SIDEBAR' } else {
|
|
142
|
+
postHID(mev(MOVED, sb.x + sb.w/2, sb.y + sb.h/2, 0)); sleep(50);
|
|
143
|
+
var n = ${n}, dir = n < 0 ? -1 : 1;
|
|
144
|
+
for (var i = 0; i < Math.abs(n); i++) {
|
|
145
|
+
var ev = $.CGEventCreateScrollWheelEvent($(), $.kCGScrollEventUnitLine, 1, dir * 3);
|
|
146
|
+
postHID(ev); sleep(60);
|
|
147
|
+
}
|
|
148
|
+
'DONE'
|
|
149
|
+
}`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Drag: the NATIVE1 gesture verbatim — move, down, 3px wiggle to open the drag
|
|
153
|
+
* session, ~25 interpolated drag events, a settle so the drop indicator locks,
|
|
154
|
+
* then up. Coordinates are AX-resolved frames + slot-boundary geometry computed
|
|
155
|
+
* by the caller in the SAME snapshot generation.
|
|
156
|
+
*/
|
|
157
|
+
export function jxaSidebarDragScript(sx, sy, tx, ty) {
|
|
158
|
+
const [a, b, c, d] = [sx, sy, tx, ty].map(Math.round);
|
|
159
|
+
return `${JXA_PRELUDE}
|
|
160
|
+
var sx=${a}, sy=${b}, tx=${c}, ty=${d}, steps=25;
|
|
161
|
+
postHID(mev(MOVED, sx, sy, 0)); sleep(30);
|
|
162
|
+
postHID(mev(DOWN, sx, sy, 1)); sleep(120);
|
|
163
|
+
postHID(mev(DRAG, sx, sy - 3, 1)); sleep(30);
|
|
164
|
+
for (var i = 1; i <= steps; i++) { postHID(mev(DRAG, sx + (tx-sx)*i/steps, sy + (ty-sy)*i/steps, 1)); sleep(25) }
|
|
165
|
+
postHID(mev(DRAG, tx, ty, 1)); sleep(400);
|
|
166
|
+
postHID(mev(UP, tx, ty, 1));
|
|
167
|
+
'DONE'`;
|
|
168
|
+
}
|
|
169
|
+
function snapshotCommand() {
|
|
170
|
+
return {
|
|
171
|
+
primitive: "sidebar-snapshot",
|
|
172
|
+
label: "read the sidebar rows and viewport",
|
|
173
|
+
lang: "javascript",
|
|
174
|
+
script: jxaSidebarSnapshotScript(),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function scrollCommand(clicks) {
|
|
178
|
+
return {
|
|
179
|
+
primitive: "sidebar-scroll",
|
|
180
|
+
label: `scroll the sidebar (${clicks} clicks)`,
|
|
181
|
+
lang: "javascript",
|
|
182
|
+
script: jxaSidebarScrollScript(clicks),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function dragCommand(sx, sy, tx, ty) {
|
|
186
|
+
return {
|
|
187
|
+
primitive: "sidebar-drag",
|
|
188
|
+
label: "drag the area row to the computed slot boundary",
|
|
189
|
+
lang: "javascript",
|
|
190
|
+
script: jxaSidebarDragScript(sx, sy, tx, ty),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Rung 2 — scroll-while-held (AXDRAG2-a: wheel events DO scroll the list while
|
|
195
|
+
* a drag is held, and AX frames re-resolve fresh mid-drag). One atomic
|
|
196
|
+
* gesture: grab the source, post wheel events until the anchor row's LIVE
|
|
197
|
+
* frame enters the visible band (direction re-derived every tick from the
|
|
198
|
+
* live frame), then move to the live-computed boundary and drop. No static
|
|
199
|
+
* corrections are needed — the mid-drag layout already reflects the lifted
|
|
200
|
+
* source. If the anchor never arrives within the tick budget, the script
|
|
201
|
+
* Escape-aborts (AXDRAG1-d: byte-identical index vector) and reports it.
|
|
202
|
+
*/
|
|
203
|
+
export function jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, // null = drop below the last row (to-last)
|
|
204
|
+
maxTicks) {
|
|
205
|
+
const [a, b] = [sx, sy].map(Math.round);
|
|
206
|
+
const anchor = JSON.stringify(anchorTitle);
|
|
207
|
+
return `${JXA_PRELUDE}
|
|
208
|
+
function allText(el, acc, depth){ acc=acc||[]; depth=depth==null?6:depth; if(depth<0) return acc;
|
|
209
|
+
var v=sv(el,'AXValue'); if(v) acc.push(v); var d=sv(el,'AXDescription'); if(d) acc.push(d);
|
|
210
|
+
var t=sv(el,'AXTitle'); if(t) acc.push(t); var ch=kids(el); for(var i=0;i<ch.length;i++) allText(ch[i],acc,depth-1); return acc }
|
|
211
|
+
function liveRows(){ var t=sidebarTable(); if(!t) return []; var out=[]; var ch=kids(t);
|
|
212
|
+
for(var r=0;r<ch.length;r++){ var role=sv(ch[r],'AXRole');
|
|
213
|
+
if(role==='AXRow'||role==='AXTableRow'){ var f=frame(ch[r]);
|
|
214
|
+
if(f) out.push({text:allText(ch[r],[],6).join('|'), f:f}) } }
|
|
215
|
+
out.sort(function(p,q){ return p.f.y-q.f.y }); return out }
|
|
216
|
+
function matches(text, title){ var segs=text.split('|');
|
|
217
|
+
for(var j=0;j<segs.length;j++){ if(segs[j]===title||segs[j]===title+'.') return true } return false }
|
|
218
|
+
function viewportRect(){ var w=stdWindow(); if(!w) return null; var sas=findAll(w,'AXScrollArea',12,[]);
|
|
219
|
+
for(var i=0;i<sas.length;i++){ var f=frame(sas[i]); if(f && f.w<400) return f } return null }
|
|
220
|
+
var sx=${a}, sy=${b}, anchorTitle=${anchor}, maxTicks=${Math.trunc(maxTicks)};
|
|
221
|
+
var vp = viewportRect();
|
|
222
|
+
if (vp === null) { JSON.stringify({aborted:true, why:'no sidebar viewport'}) } else {
|
|
223
|
+
var bandTop = vp.y + 6, bandBot = vp.y + vp.h - 6;
|
|
224
|
+
postHID(mev(MOVED, sx, sy, 0)); sleep(30);
|
|
225
|
+
postHID(mev(DOWN, sx, sy, 1)); sleep(120);
|
|
226
|
+
postHID(mev(DRAG, sx, sy - 3, 1)); sleep(30);
|
|
227
|
+
postHID(mev(DRAG, sx, sy, 1)); sleep(100);
|
|
228
|
+
function boundaryNow(){
|
|
229
|
+
var rows = liveRows(); if (rows.length === 0) return null;
|
|
230
|
+
if (anchorTitle === null) {
|
|
231
|
+
var last = rows[rows.length - 1];
|
|
232
|
+
var y = last.text === '' ? last.f.y + last.f.h/2 : last.f.y + last.f.h + last.f.h/4;
|
|
233
|
+
return { y: y, ready: (y >= bandTop && y <= bandBot) ? 1 : 0, dir: y > bandBot ? -3 : 3 };
|
|
234
|
+
}
|
|
235
|
+
var anchor = null, above = null;
|
|
236
|
+
for (var i = 0; i < rows.length; i++) {
|
|
237
|
+
if (matches(rows[i].text, anchorTitle)) { anchor = rows[i]; above = i > 0 ? rows[i-1] : null; break }
|
|
238
|
+
}
|
|
239
|
+
if (anchor === null) return null;
|
|
240
|
+
var y;
|
|
241
|
+
if (above === null) y = anchor.f.y + anchor.f.h/4;
|
|
242
|
+
else if (above.text === '') y = above.f.y + above.f.h/2;
|
|
243
|
+
else y = (above.f.y + above.f.h + anchor.f.y) / 2;
|
|
244
|
+
return { y: y, ready: (y >= bandTop && y <= bandBot) ? 1 : 0, dir: y > bandBot ? -3 : 3 };
|
|
245
|
+
}
|
|
246
|
+
var b0 = boundaryNow(), ticks = 0, result = null;
|
|
247
|
+
if (b0 === null) {
|
|
248
|
+
var kd=$.CGEventCreateKeyboardEvent($(),53,true), ku=$.CGEventCreateKeyboardEvent($(),53,false);
|
|
249
|
+
postHID(kd); sleep(20); postHID(ku); sleep(150); postHID(mev(UP, sx, sy, 1));
|
|
250
|
+
result = {aborted:true, why:'anchor row did not resolve mid-drag'};
|
|
251
|
+
} else {
|
|
252
|
+
while (b0 !== null && b0.ready === 0 && ticks < maxTicks) {
|
|
253
|
+
var ev = $.CGEventCreateScrollWheelEvent($(), $.kCGScrollEventUnitLine, 1, b0.dir);
|
|
254
|
+
postHID(ev); sleep(60);
|
|
255
|
+
postHID(mev(DRAG, sx, sy, 1)); sleep(90);
|
|
256
|
+
b0 = boundaryNow(); ticks++;
|
|
257
|
+
}
|
|
258
|
+
if (b0 === null || b0.ready === 0) {
|
|
259
|
+
var kd2=$.CGEventCreateKeyboardEvent($(),53,true), ku2=$.CGEventCreateKeyboardEvent($(),53,false);
|
|
260
|
+
postHID(kd2); sleep(20); postHID(ku2); sleep(150); postHID(mev(UP, sx, sy, 1));
|
|
261
|
+
result = {aborted:true, why:'anchor never entered the band', ticks:ticks};
|
|
262
|
+
} else {
|
|
263
|
+
// Post-wheel SETTLE: the list can drift a few px after the last tick
|
|
264
|
+
// (AXDRAG2-a saw ~8px). Wait until the live boundary is stable across
|
|
265
|
+
// two consecutive reads before aiming.
|
|
266
|
+
var stable = 0, lastY = null, w = 0;
|
|
267
|
+
for (w = 0; w < 12 && stable < 2; w++) {
|
|
268
|
+
postHID(mev(DRAG, sx, sy, 1)); sleep(140);
|
|
269
|
+
var bs = boundaryNow(); if (bs === null) break;
|
|
270
|
+
if (lastY !== null && Math.abs(bs.y - lastY) < 1) stable++;
|
|
271
|
+
else stable = 0;
|
|
272
|
+
lastY = bs.y; b0 = bs;
|
|
273
|
+
}
|
|
274
|
+
if (b0 === null || b0.ready === 0 || stable < 2) {
|
|
275
|
+
var kd3=$.CGEventCreateKeyboardEvent($(),53,true), ku3=$.CGEventCreateKeyboardEvent($(),53,false);
|
|
276
|
+
postHID(kd3); sleep(20); postHID(ku3); sleep(150); postHID(mev(UP, sx, sy, 1));
|
|
277
|
+
result = {aborted:true, why:'boundary never stabilized in the band before the drop', ticks:ticks};
|
|
278
|
+
} else {
|
|
279
|
+
var ty = b0.y;
|
|
280
|
+
for (var s = 1; s <= 15; s++) { postHID(mev(DRAG, sx, sy + (ty-sy)*s/15, 1)); sleep(25) }
|
|
281
|
+
// Final re-resolve at the destination: aim the LAST event at the
|
|
282
|
+
// freshest boundary in case anything shifted during the approach.
|
|
283
|
+
var bf = boundaryNow();
|
|
284
|
+
if (bf !== null && bf.ready === 1) ty = bf.y;
|
|
285
|
+
postHID(mev(DRAG, sx, ty, 1)); sleep(400);
|
|
286
|
+
postHID(mev(UP, sx, ty, 1));
|
|
287
|
+
result = {dropped:true, ticks:ticks, dropY:ty};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
JSON.stringify(result)
|
|
292
|
+
}`;
|
|
293
|
+
}
|
|
294
|
+
function heldScrollDragCommand(sx, sy, anchorTitle, maxTicks) {
|
|
295
|
+
return {
|
|
296
|
+
primitive: "sidebar-held-drag",
|
|
297
|
+
label: "held-scroll drag toward the destination",
|
|
298
|
+
lang: "javascript",
|
|
299
|
+
script: jxaSidebarHeldScrollDragScript(sx, sy, anchorTitle, maxTicks),
|
|
300
|
+
meta: { sx, sy, anchorTitle, maxTicks },
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
// ---------------------------------------------------------- pure geometry
|
|
304
|
+
// NO hardcoded pixel geometry: aimed coordinates derive from resolved frames
|
|
305
|
+
// of the current snapshot (amendment 2026-07-15). The only fixed numbers are
|
|
306
|
+
// TOLERANCES (visibility margins), never aim points.
|
|
307
|
+
/** Visibility tolerance: keep grabs/drops at least this far inside the band. */
|
|
308
|
+
const BAND_PAD = 6;
|
|
309
|
+
/** Median of a non-empty number list (helper for frame-derived estimates). */
|
|
310
|
+
function median(values) {
|
|
311
|
+
if (values.length === 0)
|
|
312
|
+
return null;
|
|
313
|
+
const sorted = values.toSorted((a, b) => a - b);
|
|
314
|
+
return sorted[Math.floor(sorted.length / 2)] ?? null;
|
|
315
|
+
}
|
|
316
|
+
/** Median height of the table's spacer rows (rows with no static text). */
|
|
317
|
+
export function medianSpacerHeight(rows) {
|
|
318
|
+
return median(rows.filter((r) => r.text === "").map((r) => r.h));
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Slot pitch estimate: the median y-delta between adjacent resolved area rows
|
|
322
|
+
* (falls back to entity-row height + spacer height when fewer than two area
|
|
323
|
+
* rows resolve).
|
|
324
|
+
*/
|
|
325
|
+
export function slotPitch(orderedAreaRows, allRows) {
|
|
326
|
+
const deltas = [];
|
|
327
|
+
for (let i = 1; i < orderedAreaRows.length; i++) {
|
|
328
|
+
const prev = orderedAreaRows[i - 1];
|
|
329
|
+
const curr = orderedAreaRows[i];
|
|
330
|
+
deltas.push(curr.row.y - prev.row.y);
|
|
331
|
+
}
|
|
332
|
+
const m = median(deltas);
|
|
333
|
+
if (m !== null && m > 0)
|
|
334
|
+
return m;
|
|
335
|
+
const entityH = median(allRows.filter((r) => r.text !== "").map((r) => r.h)) ?? 24;
|
|
336
|
+
const spacerH = medianSpacerHeight(allRows) ?? entityH / 2;
|
|
337
|
+
return entityH + spacerH;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* The boundary Y that inserts ABOVE `ref`: the midpoint of the region between
|
|
341
|
+
* `ref` and the row immediately above it (the spacer row's center when a
|
|
342
|
+
* spacer sits there; the shared edge when rows are contiguous). With no row
|
|
343
|
+
* above, half a spacer height above `ref` (derived, not assumed).
|
|
344
|
+
*/
|
|
345
|
+
export function boundaryAboveRow(allRows, ref) {
|
|
346
|
+
const above = allRows.filter((r) => r !== ref && r.y < ref.y).toSorted((a, b) => b.y - a.y)[0];
|
|
347
|
+
if (above === undefined) {
|
|
348
|
+
// Nothing above (no gap exists): aim inside the row's TOP QUARTER — a
|
|
349
|
+
// drop in a row's top half resolves to insert-BEFORE it (AXDRAG1-a/D1).
|
|
350
|
+
return ref.y + ref.h / 4;
|
|
351
|
+
}
|
|
352
|
+
if (above.text === "")
|
|
353
|
+
return above.y + above.h / 2; // spacer center
|
|
354
|
+
return (above.y + above.h + ref.y) / 2; // shared-edge midpoint
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The boundary Y that drops BELOW the final table row (to-last). The drop
|
|
358
|
+
* zone below the last entity IS the trailing spacer row when one exists
|
|
359
|
+
* (AXDRAG1-b landed to-last inside it) — its center keeps the drop in the
|
|
360
|
+
* scrollable band even when the list is pinned to the bottom.
|
|
361
|
+
*/
|
|
362
|
+
export function boundaryBelowLast(allRows) {
|
|
363
|
+
const last = allRows.toSorted((a, b) => a.y - b.y).at(-1);
|
|
364
|
+
if (last === undefined)
|
|
365
|
+
return null;
|
|
366
|
+
if (last.text === "")
|
|
367
|
+
return last.y + last.h / 2; // trailing spacer center
|
|
368
|
+
const half = (medianSpacerHeight(allRows) ?? last.h / 2) / 2;
|
|
369
|
+
return last.y + last.h + half;
|
|
370
|
+
}
|
|
371
|
+
export function parseSidebarSnapshot(stdout) {
|
|
372
|
+
try {
|
|
373
|
+
const parsed = JSON.parse(stdout.trim());
|
|
374
|
+
if (typeof parsed !== "object" || parsed === null || !Array.isArray(parsed.rows))
|
|
375
|
+
return null;
|
|
376
|
+
return {
|
|
377
|
+
viewport: parsed.viewport ?? null,
|
|
378
|
+
scroll: parsed.scroll ?? null,
|
|
379
|
+
rows: parsed.rows.filter((r) => typeof r.y === "number" && typeof r.x === "number" && typeof r.h === "number"),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Does a row's static-text carry this exact title as a segment? Sidebar row
|
|
388
|
+
* text concatenates descendant static texts with "|" (AXDRAG1: e.g.
|
|
389
|
+
* "Area-05.|Source Toggle Template|Area-05") — an exact segment match avoids
|
|
390
|
+
* substring collisions; the trailing-dot variant covers the AXDescription-like
|
|
391
|
+
* first segment some rows carry.
|
|
392
|
+
*/
|
|
393
|
+
export function rowMatchesTitle(text, title) {
|
|
394
|
+
return text.split("|").some((seg) => seg === title || seg === `${title}.`);
|
|
395
|
+
}
|
|
396
|
+
/** All rows matching known area titles, in visual (y) order. */
|
|
397
|
+
export function areaRowsInOrder(rows, titles) {
|
|
398
|
+
const out = [];
|
|
399
|
+
for (const row of rows) {
|
|
400
|
+
const title = titles.find((t) => rowMatchesTitle(row.text, t));
|
|
401
|
+
if (title !== undefined)
|
|
402
|
+
out.push({ title, row });
|
|
403
|
+
}
|
|
404
|
+
return out.toSorted((a, b) => a.row.y - b.row.y);
|
|
405
|
+
}
|
|
406
|
+
/** Find a single area row by title; ambiguous or missing → null. */
|
|
407
|
+
export function findAreaRow(rows, title) {
|
|
408
|
+
const matches = rows.filter((r) => rowMatchesTitle(r.text, title));
|
|
409
|
+
return matches.length === 1 ? (matches[0] ?? null) : null;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* The source's group span: lifting an area collapses its row AND its visible
|
|
413
|
+
* nested project rows. Computed from resolved frames as the distance from the
|
|
414
|
+
* source area row's top to the NEXT area row's top (falls back to the median
|
|
415
|
+
* slot pitch when the source is the last area).
|
|
416
|
+
*/
|
|
417
|
+
export function sourceGroupSpan(orderedAreaRows, sourceTitle, allRows) {
|
|
418
|
+
const pitch = slotPitch(orderedAreaRows, allRows);
|
|
419
|
+
const idx = orderedAreaRows.findIndex((a) => a.title === sourceTitle);
|
|
420
|
+
if (idx < 0)
|
|
421
|
+
return pitch;
|
|
422
|
+
const source = orderedAreaRows[idx];
|
|
423
|
+
const next = orderedAreaRows[idx + 1];
|
|
424
|
+
if (source === undefined || next === undefined)
|
|
425
|
+
return pitch;
|
|
426
|
+
const span = next.row.y - source.row.y;
|
|
427
|
+
return span > 0 ? span : pitch;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Static drop boundary Y for the requested placement, computed against the
|
|
431
|
+
* CURRENT snapshot. Every placement reduces to "insert above area R" (the
|
|
432
|
+
* mid-gap above R's row) except to-last, which drops below the final table row.
|
|
433
|
+
*/
|
|
434
|
+
export function staticBoundaryY(orderedAreaRows, allRows, sourceTitle, placement) {
|
|
435
|
+
const others = orderedAreaRows.filter((a) => a.title !== sourceTitle);
|
|
436
|
+
if (others.length === 0)
|
|
437
|
+
return { error: "no other area rows resolved in the sidebar" };
|
|
438
|
+
const belowLast = () => {
|
|
439
|
+
const y = boundaryBelowLast(allRows);
|
|
440
|
+
if (y === null)
|
|
441
|
+
return { error: "no sidebar rows resolved" };
|
|
442
|
+
return { y, anchor: "the end of the sidebar list" };
|
|
443
|
+
};
|
|
444
|
+
switch (placement.kind) {
|
|
445
|
+
case "first": {
|
|
446
|
+
const first = others[0];
|
|
447
|
+
return { y: boundaryAboveRow(allRows, first.row), anchor: first.title };
|
|
448
|
+
}
|
|
449
|
+
case "last":
|
|
450
|
+
return belowLast();
|
|
451
|
+
case "before": {
|
|
452
|
+
const ref = others.find((a) => a.title === placement.title);
|
|
453
|
+
if (ref === undefined)
|
|
454
|
+
return { error: `the sidebar row for "${placement.title}" did not resolve` };
|
|
455
|
+
return { y: boundaryAboveRow(allRows, ref.row), anchor: ref.title };
|
|
456
|
+
}
|
|
457
|
+
case "after": {
|
|
458
|
+
const refIdx = others.findIndex((a) => a.title === placement.title);
|
|
459
|
+
if (refIdx < 0)
|
|
460
|
+
return { error: `the sidebar row for "${placement.title}" did not resolve` };
|
|
461
|
+
const next = others[refIdx + 1];
|
|
462
|
+
if (next === undefined)
|
|
463
|
+
return belowLast();
|
|
464
|
+
return { y: boundaryAboveRow(allRows, next.row), anchor: next.title };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Correct a static boundary for the mid-drag layout: a DOWNWARD drag (boundary
|
|
470
|
+
* below the source) sees everything below the source shift up by the source's
|
|
471
|
+
* group span the instant it is picked up (AXDRAG1-a). Upward drags use static
|
|
472
|
+
* coordinates unchanged.
|
|
473
|
+
*/
|
|
474
|
+
export function correctedDropY(staticY, sourceCenterY, span) {
|
|
475
|
+
return staticY > sourceCenterY ? staticY - span : staticY;
|
|
476
|
+
}
|
|
477
|
+
export function inBand(y, viewport, pad = BAND_PAD) {
|
|
478
|
+
return y >= viewport.y + pad && y <= viewport.y + viewport.h - pad;
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Grab/drop x: a fixed FRACTION of the row's resolved width (≈ the label area
|
|
482
|
+
* NATIVE1 clicked at x+170 on a 240px row), clear of the leading icon and the
|
|
483
|
+
* trailing counters — derived from the frame, not a pixel offset.
|
|
484
|
+
*/
|
|
485
|
+
export function grabPoint(row) {
|
|
486
|
+
return { x: row.x + row.w * 0.7, y: row.y + row.h / 2 };
|
|
487
|
+
}
|
|
488
|
+
// ------------------------------------------------------------ DB position
|
|
489
|
+
function positionOf(state, uuid) {
|
|
490
|
+
return state.areas.findIndex((a) => a.uuid === uuid);
|
|
491
|
+
}
|
|
492
|
+
function hasRankTies(state) {
|
|
493
|
+
const seen = new Set();
|
|
494
|
+
for (const a of state.areas) {
|
|
495
|
+
if (seen.has(a.index))
|
|
496
|
+
return true;
|
|
497
|
+
seen.add(a.index);
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
/** Is the requested placement already satisfied in this DB state? */
|
|
502
|
+
export function placementSatisfied(state, targetUuid, placement) {
|
|
503
|
+
const pos = positionOf(state, targetUuid);
|
|
504
|
+
if (pos < 0)
|
|
505
|
+
return false;
|
|
506
|
+
switch (placement.kind) {
|
|
507
|
+
case "first":
|
|
508
|
+
return pos === 0;
|
|
509
|
+
case "last":
|
|
510
|
+
return pos === state.areas.length - 1;
|
|
511
|
+
case "before":
|
|
512
|
+
return positionOf(state, placement.uuid) === pos + 1;
|
|
513
|
+
case "after":
|
|
514
|
+
return positionOf(state, placement.uuid) === pos - 1;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// ---------------------------------------------------------------- driver
|
|
518
|
+
/** Absolute ceiling on hops regardless of the computed cap (safety net). */
|
|
519
|
+
const MAX_HOPS_CEILING = 40;
|
|
520
|
+
const MAX_SCROLL_ITER = 18;
|
|
521
|
+
/**
|
|
522
|
+
* Seed for the FIRST scroll's travel-per-click estimate only; every later
|
|
523
|
+
* scroll uses the travel MEASURED from the frames the previous scroll moved
|
|
524
|
+
* (no assumed pixel distances — amendment 2026-07-15).
|
|
525
|
+
*/
|
|
526
|
+
const PX_PER_CLICK_SEED = 30;
|
|
527
|
+
/** DB assert poll: attempts × delay (Things writes the index on drop). */
|
|
528
|
+
const ASSERT_ATTEMPTS = 12;
|
|
529
|
+
const ASSERT_DELAY_MS = 250;
|
|
530
|
+
const STEP_TIMEOUT_MS = 30_000;
|
|
531
|
+
async function runCmd(ctx, cmd) {
|
|
532
|
+
return ctx.run(cmd, STEP_TIMEOUT_MS);
|
|
533
|
+
}
|
|
534
|
+
async function takeSnapshot(ctx) {
|
|
535
|
+
const res = await runCmd(ctx, snapshotCommand());
|
|
536
|
+
if (!res.ok)
|
|
537
|
+
return null;
|
|
538
|
+
const snap = parseSidebarSnapshot(res.stdout);
|
|
539
|
+
if (snap === null || snap.viewport === null || snap.rows.length === 0)
|
|
540
|
+
return null;
|
|
541
|
+
return snap;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Scroll until `wanted(snapshot)` returns a zero-ish error, re-resolving
|
|
545
|
+
* frames after every scroll (AXDRAG1: frames must be re-read post-scroll).
|
|
546
|
+
* `wanted` returns the pixel error to correct (positive = rows must move down)
|
|
547
|
+
* or null when satisfied. Self-calibrating: if a scroll moves the rows the
|
|
548
|
+
* wrong way, the direction factor flips once.
|
|
549
|
+
*/
|
|
550
|
+
async function scrollUntil(ctx, wanted, goodEnough) {
|
|
551
|
+
let dirFactor = 1;
|
|
552
|
+
let pxPerClick = PX_PER_CLICK_SEED; // replaced by measured travel after scroll 1
|
|
553
|
+
let lastErr = null;
|
|
554
|
+
let lastClicks = 0;
|
|
555
|
+
let stalls = 0;
|
|
556
|
+
for (let iter = 0; iter < MAX_SCROLL_ITER; iter++) {
|
|
557
|
+
// each scroll must observe the frames the previous scroll produced
|
|
558
|
+
const snap = await takeSnapshot(ctx);
|
|
559
|
+
if (snap === null)
|
|
560
|
+
return null;
|
|
561
|
+
const err = wanted(snap);
|
|
562
|
+
if (err === null)
|
|
563
|
+
return snap;
|
|
564
|
+
if (lastErr !== null && lastClicks !== 0) {
|
|
565
|
+
const moved = lastErr - err; // px the content actually travelled
|
|
566
|
+
if (Math.abs(moved) < 2) {
|
|
567
|
+
stalls += 1;
|
|
568
|
+
if (stalls >= 2) {
|
|
569
|
+
// Pinned at the end of the scroll range: settle for the achieved
|
|
570
|
+
// state when the caller says it is workable.
|
|
571
|
+
return goodEnough !== undefined && goodEnough(snap) ? snap : null;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
stalls = 0;
|
|
576
|
+
// Calibrate from the MEASURED travel of the previous scroll.
|
|
577
|
+
pxPerClick = Math.min(120, Math.max(4, Math.abs(moved / lastClicks)));
|
|
578
|
+
// Moved the wrong way → the wheel sign convention is flipped here.
|
|
579
|
+
if (Math.sign(moved) !== Math.sign(lastClicks))
|
|
580
|
+
dirFactor = -dirFactor;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
lastErr = err;
|
|
584
|
+
const clicks = Math.max(-12, Math.min(12, Math.round(err / pxPerClick) || Math.sign(err))) * dirFactor;
|
|
585
|
+
lastClicks = clicks;
|
|
586
|
+
// strictly sequential scroll-and-remeasure loop
|
|
587
|
+
const res = await runCmd(ctx, scrollCommand(clicks));
|
|
588
|
+
if (!res.ok)
|
|
589
|
+
return null;
|
|
590
|
+
}
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
/** Poll the DB until `check` passes or the attempts run out. */
|
|
594
|
+
async function pollState(ctx, check) {
|
|
595
|
+
for (let i = 0; i < ASSERT_ATTEMPTS; i++) {
|
|
596
|
+
const state = ctx.state();
|
|
597
|
+
if (check(state))
|
|
598
|
+
return state;
|
|
599
|
+
// polling the same DB condition is inherently sequential
|
|
600
|
+
await ctx.sleep(ASSERT_DELAY_MS);
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
/** Resolve source row + corrected drop Y against ONE snapshot generation. */
|
|
605
|
+
function planDrop(snap, spec, areaTitles, placement) {
|
|
606
|
+
const source = findAreaRow(snap.rows, spec.targetTitle);
|
|
607
|
+
if (source === null) {
|
|
608
|
+
return {
|
|
609
|
+
error: `the sidebar row for "${spec.targetTitle}" did not resolve uniquely by its visible ` +
|
|
610
|
+
"name — after many drags in one app session a sidebar row can stop exposing its " +
|
|
611
|
+
"name until Things is relaunched (AXDRAG2); quit and reopen Things, then retry",
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
const ordered = areaRowsInOrder(snap.rows, areaTitles);
|
|
615
|
+
const boundary = staticBoundaryY(ordered, snap.rows, spec.targetTitle, placement);
|
|
616
|
+
if ("error" in boundary)
|
|
617
|
+
return boundary;
|
|
618
|
+
const span = sourceGroupSpan(ordered, spec.targetTitle, snap.rows);
|
|
619
|
+
const sourceCenter = source.y + source.h / 2;
|
|
620
|
+
return {
|
|
621
|
+
source,
|
|
622
|
+
dropY: correctedDropY(boundary.y, sourceCenter, span),
|
|
623
|
+
anchor: boundary.anchor,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
/** One drag gesture from the source row to the corrected boundary. */
|
|
627
|
+
async function performDrag(ctx, drop) {
|
|
628
|
+
const grab = grabPoint(drop.source);
|
|
629
|
+
const res = await runCmd(ctx, dragCommand(grab.x, grab.y, grab.x, drop.dropY));
|
|
630
|
+
return res.ok && res.stdout.includes("DONE");
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* The rung-2 anchor: every placement reduces to "drop above this area row"
|
|
634
|
+
* (title) or "drop below the last row" (null). `undefined` = unresolvable.
|
|
635
|
+
*/
|
|
636
|
+
export function rung2AnchorTitle(rows, areaTitles, sourceTitle, placement) {
|
|
637
|
+
const others = areaRowsInOrder(rows, areaTitles).filter((a) => a.title !== sourceTitle);
|
|
638
|
+
if (others.length === 0)
|
|
639
|
+
return undefined;
|
|
640
|
+
switch (placement.kind) {
|
|
641
|
+
case "first":
|
|
642
|
+
return others[0].title;
|
|
643
|
+
case "last":
|
|
644
|
+
return null;
|
|
645
|
+
case "before":
|
|
646
|
+
return others.some((a) => a.title === placement.title) ? placement.title : undefined;
|
|
647
|
+
case "after": {
|
|
648
|
+
const i = others.findIndex((a) => a.title === placement.title);
|
|
649
|
+
if (i < 0)
|
|
650
|
+
return undefined;
|
|
651
|
+
const next = others[i + 1];
|
|
652
|
+
return next === undefined ? null : next.title;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function parseHeldDragResult(res) {
|
|
657
|
+
if (!res.ok)
|
|
658
|
+
return { dropped: false };
|
|
659
|
+
try {
|
|
660
|
+
const parsed = JSON.parse(res.stdout.trim());
|
|
661
|
+
return parsed.dropped === true
|
|
662
|
+
? { dropped: true, ...(typeof parsed.ticks === "number" && { ticks: parsed.ticks }) }
|
|
663
|
+
: { dropped: false };
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
return { dropped: false };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
function invariantsHold(pre, now) {
|
|
670
|
+
return now.areas.length === pre.areas.length && now.assignmentsDigest === pre.assignmentsDigest;
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* Drive the full ladder. `preState` is captured ONCE up front for the whole
|
|
674
|
+
* move (recovery + the caller's undo capture both key off it, not per hop).
|
|
675
|
+
*/
|
|
676
|
+
export async function driveSidebarAreaReorder(spec, run, aux, sleep = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
677
|
+
if (aux.areaState === undefined) {
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
detail: "the sidebar drag driver has no database seam on this surface — this operation can " +
|
|
681
|
+
"only run through the full client",
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
const ctx = { run, state: aux.areaState, sleep };
|
|
685
|
+
const pre = ctx.state();
|
|
686
|
+
const preTies = hasRankTies(pre);
|
|
687
|
+
const areaTitles = pre.areas.map((a) => a.title);
|
|
688
|
+
if (positionOf(pre, spec.targetUuid) < 0) {
|
|
689
|
+
return { ok: false, detail: `area ${spec.targetUuid} no longer exists` };
|
|
690
|
+
}
|
|
691
|
+
if (!preTies && placementSatisfied(pre, spec.targetUuid, spec.placement)) {
|
|
692
|
+
return { ok: true, detail: "already in the requested position — nothing to move" };
|
|
693
|
+
}
|
|
694
|
+
// Remaining-distance metric for the infinite-loop guard (design amendment
|
|
695
|
+
// 2026-07-15): the number of area positions between the target's current
|
|
696
|
+
// slot and its destination slot, DB-read after every hop. Each hop must
|
|
697
|
+
// STRICTLY reduce it; one retry is allowed, then the op aborts reporting
|
|
698
|
+
// where the area ended up (a partially-moved area is benign).
|
|
699
|
+
const distanceIn = (state) => {
|
|
700
|
+
const placement = spec.placement;
|
|
701
|
+
const others = state.areas.filter((a) => a.uuid !== spec.targetUuid);
|
|
702
|
+
const cur = positionOf(state, spec.targetUuid);
|
|
703
|
+
if (cur < 0)
|
|
704
|
+
return null;
|
|
705
|
+
let want;
|
|
706
|
+
switch (placement.kind) {
|
|
707
|
+
case "first":
|
|
708
|
+
want = 0;
|
|
709
|
+
break;
|
|
710
|
+
case "last":
|
|
711
|
+
want = others.length;
|
|
712
|
+
break;
|
|
713
|
+
case "before": {
|
|
714
|
+
const i = others.findIndex((a) => a.uuid === placement.uuid);
|
|
715
|
+
if (i < 0)
|
|
716
|
+
return null;
|
|
717
|
+
want = i;
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
case "after": {
|
|
721
|
+
const i = others.findIndex((a) => a.uuid === placement.uuid);
|
|
722
|
+
if (i < 0)
|
|
723
|
+
return null;
|
|
724
|
+
want = i + 1;
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return Math.abs(want - cur);
|
|
729
|
+
};
|
|
730
|
+
let hops = 0;
|
|
731
|
+
let remaining = preTies ? null : distanceIn(pre);
|
|
732
|
+
let retried = false;
|
|
733
|
+
// Rung 2 (scroll-while-held) is BUILT and probe-certified (AXDRAG2-a) but
|
|
734
|
+
// ships DISABLED: production certification exposed an app-side instability
|
|
735
|
+
// — after drag+scroll churn the sidebar's AX mirror can drop or blank row
|
|
736
|
+
// elements until Things is relaunched (AXDRAG2-c / oddities), and held
|
|
737
|
+
// travel beyond ~1.5 viewports is the strongest trigger. The certified
|
|
738
|
+
// ladder is rung 1 + the multi-hop floor; THINGS_UI_DRAG_LADDER=held-scroll
|
|
739
|
+
// re-enables rung 2 for lab work. Attempted at most ONCE per move; a clean
|
|
740
|
+
// abort falls through to the floor.
|
|
741
|
+
let heldScrollTried = process.env["THINGS_UI_DRAG_LADDER"] !== "held-scroll";
|
|
742
|
+
// Transient-render tolerance: the first unresolved snapshot gets one
|
|
743
|
+
// settle-and-retry before the drive refuses (the app may still be
|
|
744
|
+
// materializing sidebar rows right after launch/navigation).
|
|
745
|
+
let resolveRetried = false;
|
|
746
|
+
// A benign off-slot landing (invariants intact) lets the ladder CONTINUE
|
|
747
|
+
// from wherever the drop ended — one retry of the final placement, then an
|
|
748
|
+
// honest positional abort. Only invariant damage recovers-and-refuses.
|
|
749
|
+
let finalRetried = false;
|
|
750
|
+
// Absolute backstop: ceil(areas / visible-slots) + 2, refined from the first
|
|
751
|
+
// snapshot's viewport height (each hop covers roughly one viewport).
|
|
752
|
+
let hopCap = Math.min(MAX_HOPS_CEILING, pre.areas.length + 2);
|
|
753
|
+
for (let attempt = 0; attempt <= MAX_HOPS_CEILING; attempt++) {
|
|
754
|
+
// every hop depends on the layout the previous hop produced
|
|
755
|
+
const snap = await takeSnapshot(ctx);
|
|
756
|
+
if (snap === null) {
|
|
757
|
+
return refuseOrRecover(ctx, pre, spec, hops, "the sidebar did not resolve (is the window open and the sidebar visible?)");
|
|
758
|
+
}
|
|
759
|
+
const viewport = snap.viewport;
|
|
760
|
+
{
|
|
761
|
+
// ceil(areas / visible-slots) + 2, from the frame-derived slot pitch.
|
|
762
|
+
const pitch = slotPitch(areaRowsInOrder(snap.rows, areaTitles), snap.rows);
|
|
763
|
+
const visibleSlots = Math.max(1, Math.floor(viewport.h / pitch) - 2);
|
|
764
|
+
hopCap = Math.min(MAX_HOPS_CEILING, Math.ceil(pre.areas.length / visibleSlots) + 2);
|
|
765
|
+
}
|
|
766
|
+
const finalPlan = planDrop(snap, spec, areaTitles, spec.placement);
|
|
767
|
+
if ("error" in finalPlan) {
|
|
768
|
+
if (!resolveRetried) {
|
|
769
|
+
resolveRetried = true;
|
|
770
|
+
// one settle pause before re-reading the tree
|
|
771
|
+
await ctx.sleep(2000);
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
return refuseOrRecover(ctx, pre, spec, hops, finalPlan.error);
|
|
775
|
+
}
|
|
776
|
+
resolveRetried = false;
|
|
777
|
+
const grab = grabPoint(finalPlan.source);
|
|
778
|
+
// Rung 1: both the grab point and the drop boundary visible (or scrollable
|
|
779
|
+
// into simultaneous view) → one certified drag.
|
|
780
|
+
const spanNeeded = Math.abs(grab.y - finalPlan.dropY);
|
|
781
|
+
if (spanNeeded < viewport.h - 4 * BAND_PAD) {
|
|
782
|
+
let ready = snap;
|
|
783
|
+
if (!inBand(grab.y, viewport) || !inBand(finalPlan.dropY, viewport)) {
|
|
784
|
+
// pre-scroll must land before the drag
|
|
785
|
+
ready = await scrollUntil(ctx, (s) => {
|
|
786
|
+
const p = planDrop(s, spec, areaTitles, spec.placement);
|
|
787
|
+
if ("error" in p || s.viewport === null)
|
|
788
|
+
return null;
|
|
789
|
+
const g = grabPoint(p.source);
|
|
790
|
+
if (inBand(g.y, s.viewport) && inBand(p.dropY, s.viewport))
|
|
791
|
+
return null;
|
|
792
|
+
const mid = (g.y + p.dropY) / 2;
|
|
793
|
+
const bandMid = s.viewport.y + s.viewport.h / 2;
|
|
794
|
+
return bandMid - mid;
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
if (ready !== null) {
|
|
798
|
+
const plan = planDrop(ready, spec, areaTitles, spec.placement);
|
|
799
|
+
if (!("error" in plan) && ready.viewport !== null) {
|
|
800
|
+
const g = grabPoint(plan.source);
|
|
801
|
+
if (inBand(g.y, ready.viewport) && inBand(plan.dropY, ready.viewport)) {
|
|
802
|
+
// the gesture must land before the DB assert
|
|
803
|
+
const landed = await performDrag(ctx, plan);
|
|
804
|
+
if (!landed) {
|
|
805
|
+
return refuseOrRecover(ctx, pre, spec, hops, "the drag gesture did not complete");
|
|
806
|
+
}
|
|
807
|
+
// the final assert gates success
|
|
808
|
+
const finalState = await pollState(ctx, (s) => invariantsHold(pre, s) && placementSatisfied(s, spec.targetUuid, spec.placement));
|
|
809
|
+
if (finalState === null) {
|
|
810
|
+
const latest = ctx.state();
|
|
811
|
+
if (!invariantsHold(pre, latest)) {
|
|
812
|
+
return refuseOrRecover(ctx, pre, spec, hops, "the drop changed the area count or an area assignment (it should never)");
|
|
813
|
+
}
|
|
814
|
+
// Benign off-slot landing: let the ladder re-aim once from the
|
|
815
|
+
// new position before giving up.
|
|
816
|
+
if (!finalRetried) {
|
|
817
|
+
finalRetried = true;
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
return abortPartial(ctx, spec, hops, "the drop kept landing off the requested slot (retried once)");
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
ok: true,
|
|
824
|
+
detail: hops === 0
|
|
825
|
+
? "moved with one drag (source and destination shared a viewport)"
|
|
826
|
+
: `moved with ${hops} intermediate hop(s) + the final drag (multi-hop fallback)`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
// fall through to a hop if simultaneous visibility could not be arranged
|
|
832
|
+
}
|
|
833
|
+
// Rung 2: scroll-while-held (AXDRAG2-a GO) — one atomic gesture that
|
|
834
|
+
// scrolls the list under the held item and drops at the LIVE-resolved
|
|
835
|
+
// boundary. A clean abort (Escape, no drop) falls through to rung 3.
|
|
836
|
+
if (!heldScrollTried) {
|
|
837
|
+
heldScrollTried = true;
|
|
838
|
+
// The source must be grabbable first.
|
|
839
|
+
// the pre-grab scroll must land before the gesture
|
|
840
|
+
const grabbable = await scrollUntil(ctx, (s) => {
|
|
841
|
+
if (s.viewport === null)
|
|
842
|
+
return null;
|
|
843
|
+
const src = findAreaRow(s.rows, spec.targetTitle);
|
|
844
|
+
if (src === null)
|
|
845
|
+
return null;
|
|
846
|
+
const g = grabPoint(src);
|
|
847
|
+
if (inBand(g.y, s.viewport))
|
|
848
|
+
return null;
|
|
849
|
+
return s.viewport.y + s.viewport.h / 2 - g.y;
|
|
850
|
+
}, (s) => {
|
|
851
|
+
if (s.viewport === null)
|
|
852
|
+
return false;
|
|
853
|
+
const src = findAreaRow(s.rows, spec.targetTitle);
|
|
854
|
+
return src !== null && inBand(grabPoint(src).y, s.viewport);
|
|
855
|
+
});
|
|
856
|
+
if (grabbable !== null && grabbable.viewport !== null) {
|
|
857
|
+
const src = findAreaRow(grabbable.rows, spec.targetTitle);
|
|
858
|
+
const anchor = rung2AnchorTitle(grabbable.rows, areaTitles, spec.targetTitle, spec.placement);
|
|
859
|
+
if (src !== null && anchor !== undefined) {
|
|
860
|
+
const g = grabPoint(src);
|
|
861
|
+
const plan2 = planDrop(grabbable, spec, areaTitles, spec.placement);
|
|
862
|
+
const travel = "error" in plan2 ? viewport.h * 4 : Math.abs(plan2.dropY - g.y);
|
|
863
|
+
// TRAVEL CAP (AXDRAG2-c): held-scroll is proven up to ~1.5 viewport
|
|
864
|
+
// heights; beyond that the app's AX mirror can lose row names for
|
|
865
|
+
// the rest of the session (oddities: sidebar ghost rows), so far
|
|
866
|
+
// moves go straight to the multi-hop floor.
|
|
867
|
+
if (travel > viewport.h * 1.5)
|
|
868
|
+
continue;
|
|
869
|
+
const maxTicks = Math.min(400, Math.max(20, Math.ceil(travel / 15)));
|
|
870
|
+
// the held gesture must complete before its DB assert
|
|
871
|
+
const res = await runCmd(ctx, heldScrollDragCommand(g.x, g.y, anchor, maxTicks));
|
|
872
|
+
const parsed = parseHeldDragResult(res);
|
|
873
|
+
if (parsed.dropped) {
|
|
874
|
+
// the final assert gates success
|
|
875
|
+
const finalState = await pollState(ctx, (s) => invariantsHold(pre, s) && placementSatisfied(s, spec.targetUuid, spec.placement));
|
|
876
|
+
if (finalState !== null) {
|
|
877
|
+
return {
|
|
878
|
+
ok: true,
|
|
879
|
+
detail: `moved with one scroll-while-held drag (${parsed.ticks ?? "?"} wheel ticks` +
|
|
880
|
+
`${hops > 0 ? `, after ${hops} hop(s)` : ""})`,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
const latest = ctx.state();
|
|
884
|
+
if (!invariantsHold(pre, latest)) {
|
|
885
|
+
return refuseOrRecover(ctx, pre, spec, hops, "the scroll-while-held drop changed the area count or an area assignment " +
|
|
886
|
+
"(it should never)");
|
|
887
|
+
}
|
|
888
|
+
// Benign off-slot landing: rungs 1/3 finish the move from here.
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
// Clean abort (Escape, nothing dropped) → the multi-hop floor.
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
// Rung 3: hop one viewport toward the target.
|
|
897
|
+
hops += 1;
|
|
898
|
+
if (hops > hopCap) {
|
|
899
|
+
return abortPartial(ctx, spec, hops - 1, `exceeded the hop cap (${hopCap}) without converging`);
|
|
900
|
+
}
|
|
901
|
+
const down = finalPlan.dropY > grab.y;
|
|
902
|
+
// Bring the source into the band, parked toward the trailing edge so the
|
|
903
|
+
// viewport ahead of it is maximal.
|
|
904
|
+
// the hop's park scroll must land before its gesture
|
|
905
|
+
const parked = await scrollUntil(ctx, (s) => {
|
|
906
|
+
if (s.viewport === null)
|
|
907
|
+
return null;
|
|
908
|
+
const src = findAreaRow(s.rows, spec.targetTitle);
|
|
909
|
+
if (src === null)
|
|
910
|
+
return null;
|
|
911
|
+
const g = grabPoint(src);
|
|
912
|
+
const edge = s.viewport.h * 0.15; // park near the trailing edge (frame fraction)
|
|
913
|
+
const desired = down ? s.viewport.y + edge : s.viewport.y + s.viewport.h - edge;
|
|
914
|
+
const err = desired - g.y;
|
|
915
|
+
const tolerance = s.viewport.h * 0.05;
|
|
916
|
+
return Math.abs(err) <= tolerance && inBand(g.y, s.viewport) ? null : err;
|
|
917
|
+
},
|
|
918
|
+
// Scroll pinned before reaching the parking spot: grabbable is enough.
|
|
919
|
+
(s) => {
|
|
920
|
+
if (s.viewport === null)
|
|
921
|
+
return false;
|
|
922
|
+
const src = findAreaRow(s.rows, spec.targetTitle);
|
|
923
|
+
return src !== null && inBand(grabPoint(src).y, s.viewport);
|
|
924
|
+
});
|
|
925
|
+
if (parked === null || parked.viewport === null) {
|
|
926
|
+
return refuseOrRecover(ctx, pre, spec, hops, "could not scroll the area's row into view");
|
|
927
|
+
}
|
|
928
|
+
const ordered = areaRowsInOrder(parked.rows, areaTitles);
|
|
929
|
+
const srcIdx = ordered.findIndex((a) => a.title === spec.targetTitle);
|
|
930
|
+
const source = ordered[srcIdx];
|
|
931
|
+
if (srcIdx < 0 || source === undefined) {
|
|
932
|
+
return refuseOrRecover(ctx, pre, spec, hops, "the area's row vanished after scrolling");
|
|
933
|
+
}
|
|
934
|
+
const span = sourceGroupSpan(ordered, spec.targetTitle, parked.rows);
|
|
935
|
+
const sourceCenter = source.row.y + source.row.h / 2;
|
|
936
|
+
// Candidate anchors: area rows toward the target whose corrected boundary
|
|
937
|
+
// stays inside the visible band; take the furthest for maximum progress.
|
|
938
|
+
let hopAnchor = null;
|
|
939
|
+
for (let i = 0; i < ordered.length; i++) {
|
|
940
|
+
if (i === srcIdx)
|
|
941
|
+
continue;
|
|
942
|
+
const cand = ordered[i];
|
|
943
|
+
const isDownCand = i > srcIdx;
|
|
944
|
+
if (isDownCand !== down)
|
|
945
|
+
continue;
|
|
946
|
+
const visualDelta = Math.abs(i - srcIdx);
|
|
947
|
+
// Dropping ABOVE the anchor: downward needs ≥2 rows of travel to be a
|
|
948
|
+
// real move; upward ≥1.
|
|
949
|
+
if (down && visualDelta < 2)
|
|
950
|
+
continue;
|
|
951
|
+
const dropY = correctedDropY(boundaryAboveRow(parked.rows, cand.row), sourceCenter, span);
|
|
952
|
+
if (!inBand(dropY, parked.viewport))
|
|
953
|
+
continue;
|
|
954
|
+
const uuid = pre.areas.find((a) => a.title === cand.title)?.uuid ?? "";
|
|
955
|
+
if (hopAnchor === null || visualDelta > hopAnchor.visualDelta) {
|
|
956
|
+
hopAnchor = { title: cand.title, uuid, dropY, visualDelta };
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
if (hopAnchor === null) {
|
|
960
|
+
return refuseOrRecover(ctx, pre, spec, hops, "no drop slot toward the destination fits the visible sidebar — the viewport is too " +
|
|
961
|
+
"small to make progress");
|
|
962
|
+
}
|
|
963
|
+
const anchorUuid = hopAnchor.uuid;
|
|
964
|
+
// the hop gesture must land before its DB assert
|
|
965
|
+
const landed = await performDrag(ctx, {
|
|
966
|
+
source: source.row,
|
|
967
|
+
dropY: hopAnchor.dropY,
|
|
968
|
+
anchor: hopAnchor.title,
|
|
969
|
+
});
|
|
970
|
+
if (!landed) {
|
|
971
|
+
return refuseOrRecover(ctx, pre, spec, hops, "a hop drag gesture did not complete");
|
|
972
|
+
}
|
|
973
|
+
// DB assert after EVERY hop: the source should now sit immediately above
|
|
974
|
+
// the anchor, with the count + assignments invariant.
|
|
975
|
+
// the hop assert gates the next hop
|
|
976
|
+
const hopState = await pollState(ctx, (s) => invariantsHold(pre, s) &&
|
|
977
|
+
positionOf(s, spec.targetUuid) >= 0 &&
|
|
978
|
+
positionOf(s, spec.targetUuid) + 1 === positionOf(s, anchorUuid));
|
|
979
|
+
if (hopState !== null) {
|
|
980
|
+
// Landed as aimed. Infinite-loop guard: the remaining distance must
|
|
981
|
+
// STRICTLY shrink hop over hop.
|
|
982
|
+
const d = distanceIn(hopState);
|
|
983
|
+
if (remaining !== null && d !== null && d >= remaining) {
|
|
984
|
+
if (retried) {
|
|
985
|
+
return abortPartial(ctx, spec, hops, "two consecutive hops made no progress");
|
|
986
|
+
}
|
|
987
|
+
retried = true;
|
|
988
|
+
}
|
|
989
|
+
else {
|
|
990
|
+
retried = false;
|
|
991
|
+
if (d !== null)
|
|
992
|
+
remaining = d;
|
|
993
|
+
}
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
// The aimed adjacency never showed. Damage (count/assignment change) gets
|
|
997
|
+
// a recovery drag; a benign off-by-slots landing that still progressed is
|
|
998
|
+
// accepted; anything else burns the single retry, then aborts.
|
|
999
|
+
const latest = ctx.state();
|
|
1000
|
+
if (!invariantsHold(pre, latest)) {
|
|
1001
|
+
return refuseOrRecover(ctx, pre, spec, hops, `hop ${hops} changed the area count or an area assignment (it should never) `);
|
|
1002
|
+
}
|
|
1003
|
+
const d = distanceIn(latest);
|
|
1004
|
+
if (d !== null && (remaining === null || d < remaining)) {
|
|
1005
|
+
remaining = d;
|
|
1006
|
+
retried = false;
|
|
1007
|
+
continue;
|
|
1008
|
+
}
|
|
1009
|
+
if (!retried) {
|
|
1010
|
+
retried = true;
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
return abortPartial(ctx, spec, hops, `hop ${hops} did not land immediately above "${hopAnchor.title}" and made no progress ` +
|
|
1014
|
+
"(retried once)");
|
|
1015
|
+
}
|
|
1016
|
+
return abortPartial(ctx, spec, hops, "exceeded the hop budget without converging");
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Abort mid-ladder WITHOUT recovery (design amendment: a partially-moved area
|
|
1020
|
+
* is benign) — send Escape as a safety valve and report exactly where the
|
|
1021
|
+
* area ended up so the caller can re-run or finish by hand.
|
|
1022
|
+
*/
|
|
1023
|
+
async function abortPartial(ctx, spec, hops, why) {
|
|
1024
|
+
await runCmd(ctx, {
|
|
1025
|
+
primitive: "key",
|
|
1026
|
+
label: "abort (Escape)",
|
|
1027
|
+
script: `tell application "System Events" to key code 53`,
|
|
1028
|
+
});
|
|
1029
|
+
const state = ctx.state();
|
|
1030
|
+
const pos = positionOf(state, spec.targetUuid);
|
|
1031
|
+
const where = pos < 0
|
|
1032
|
+
? "the area no longer resolves in the database"
|
|
1033
|
+
: `the area now sits at sidebar position ${pos + 1} of ${state.areas.length}` +
|
|
1034
|
+
(pos > 0 ? `, below "${state.areas[pos - 1]?.title ?? "?"}"` : ", at the top");
|
|
1035
|
+
return {
|
|
1036
|
+
ok: false,
|
|
1037
|
+
detail: `${why} after ${hops} hop(s); ${where}. Its to-dos and projects are untouched — ` +
|
|
1038
|
+
"re-run the move or finish it in the app.",
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
/**
|
|
1042
|
+
* One verified recovery attempt: put the area back where the pre-op capture
|
|
1043
|
+
* says it was (before its old successor, or to-last), then report the original
|
|
1044
|
+
* failure with the recovery outcome appended. Never recurses.
|
|
1045
|
+
*/
|
|
1046
|
+
async function refuseOrRecover(ctx, pre, spec, hops, why) {
|
|
1047
|
+
const now = ctx.state();
|
|
1048
|
+
const moved = positionOf(now, spec.targetUuid) !== positionOf(pre, spec.targetUuid) ||
|
|
1049
|
+
now.areas.map((a) => a.uuid).join(",") !== pre.areas.map((a) => a.uuid).join(",");
|
|
1050
|
+
if (!moved || hasRankTies(pre)) {
|
|
1051
|
+
return {
|
|
1052
|
+
ok: false,
|
|
1053
|
+
detail: `${why}. No sidebar change was left behind${hops > 0 ? ` after ${hops} hop(s)` : ""}.`,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
const preIdx = positionOf(pre, spec.targetUuid);
|
|
1057
|
+
const successor = pre.areas[preIdx + 1];
|
|
1058
|
+
const placement = successor !== undefined
|
|
1059
|
+
? { kind: "before", uuid: successor.uuid, title: successor.title }
|
|
1060
|
+
: { kind: "last" };
|
|
1061
|
+
const areaTitles = pre.areas.map((a) => a.title);
|
|
1062
|
+
let recovered = false;
|
|
1063
|
+
// A bounded, single-pass recovery: same rung-1 mechanics, no hop budget.
|
|
1064
|
+
const snap = await scrollUntil(ctx, (s) => {
|
|
1065
|
+
const p = planDrop(s, spec, areaTitles, placement);
|
|
1066
|
+
if ("error" in p || s.viewport === null)
|
|
1067
|
+
return null;
|
|
1068
|
+
const g = grabPoint(p.source);
|
|
1069
|
+
if (inBand(g.y, s.viewport) && inBand(p.dropY, s.viewport))
|
|
1070
|
+
return null;
|
|
1071
|
+
return s.viewport.y + s.viewport.h / 2 - (g.y + p.dropY) / 2;
|
|
1072
|
+
});
|
|
1073
|
+
if (snap !== null && snap.viewport !== null) {
|
|
1074
|
+
const plan = planDrop(snap, spec, areaTitles, placement);
|
|
1075
|
+
if (!("error" in plan)) {
|
|
1076
|
+
const g = grabPoint(plan.source);
|
|
1077
|
+
if (inBand(g.y, snap.viewport) && inBand(plan.dropY, snap.viewport)) {
|
|
1078
|
+
const landed = await performDrag(ctx, plan);
|
|
1079
|
+
if (landed) {
|
|
1080
|
+
const state = await pollState(ctx, (s) => positionOf(s, spec.targetUuid) === preIdx);
|
|
1081
|
+
recovered = state !== null;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
return {
|
|
1087
|
+
ok: false,
|
|
1088
|
+
recovered,
|
|
1089
|
+
detail: recovered
|
|
1090
|
+
? `${why}. The area was dragged back to its previous position (verified).`
|
|
1091
|
+
: `${why}. RECOVERY DID NOT VERIFY: the area may be at an intermediate position after ` +
|
|
1092
|
+
`${hops} hop(s) — check the sidebar and re-run, or move it back in the app.`,
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
//# sourceMappingURL=ui-drag.js.map
|