arrayview 0.43.2__tar.gz → 0.43.3__tar.gz

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.
Files changed (112) hide show
  1. {arrayview-0.43.2 → arrayview-0.43.3}/PKG-INFO +1 -1
  2. {arrayview-0.43.2 → arrayview-0.43.3}/pyproject.toml +1 -1
  3. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_query.py +25 -10
  4. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_viewer.html +111 -8
  5. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_vscode_extension.py +241 -9
  6. arrayview-0.43.3/src/arrayview/arrayview-opener.vsix +0 -0
  7. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_api.py +61 -0
  8. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_browser.py +35 -0
  9. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_lifecycle_contract.py +197 -0
  10. arrayview-0.43.2/src/arrayview/arrayview-opener.vsix +0 -0
  11. {arrayview-0.43.2 → arrayview-0.43.3}/.gitignore +0 -0
  12. {arrayview-0.43.2 → arrayview-0.43.3}/LICENSE +0 -0
  13. {arrayview-0.43.2 → arrayview-0.43.3}/README.md +0 -0
  14. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/ARCHITECTURE.md +0 -0
  15. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/__init__.py +0 -0
  16. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/__main__.py +0 -0
  17. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_analysis.py +0 -0
  18. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_app.py +0 -0
  19. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_config.py +0 -0
  20. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_diagnostics.py +0 -0
  21. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_dicom.py +0 -0
  22. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_diff.py +0 -0
  23. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_extra_ports.py +0 -0
  24. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_icon.png +0 -0
  25. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_imaging.py +0 -0
  26. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_instance_registry.py +0 -0
  27. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_io.py +0 -0
  28. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_launch_plan.py +0 -0
  29. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_launch_trace.py +0 -0
  30. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_launcher.py +0 -0
  31. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_lifecycle.py +0 -0
  32. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_overlays.py +0 -0
  33. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_platform.py +0 -0
  34. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_render.py +0 -0
  35. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_analysis.py +0 -0
  36. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_drop.py +0 -0
  37. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_export.py +0 -0
  38. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_loading.py +0 -0
  39. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_persistence.py +0 -0
  40. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_preferences.py +0 -0
  41. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_preload.py +0 -0
  42. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_rendering.py +0 -0
  43. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_segmentation.py +0 -0
  44. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_state.py +0 -0
  45. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_vectorfield.py +0 -0
  46. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_routes_websocket.py +0 -0
  47. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_segmentation.py +0 -0
  48. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_server.py +0 -0
  49. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_session.py +0 -0
  50. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_session_spec.py +0 -0
  51. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_shell.html +0 -0
  52. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_source_safety.py +0 -0
  53. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_synthetic_mri.py +0 -0
  54. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_torch.py +0 -0
  55. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_tutorial.py +0 -0
  56. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_vectorfield.py +0 -0
  57. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_vscode.py +0 -0
  58. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_vscode_browser.py +0 -0
  59. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_vscode_signal.py +0 -0
  60. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/_watch.py +0 -0
  61. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/gsap.min.js +0 -0
  62. {arrayview-0.43.2 → arrayview-0.43.3}/src/arrayview/todo.md +0 -0
  63. {arrayview-0.43.2 → arrayview-0.43.3}/tests/capture_patient_loading.py +0 -0
  64. {arrayview-0.43.2 → arrayview-0.43.3}/tests/capture_v_animation.py +0 -0
  65. {arrayview-0.43.2 → arrayview-0.43.3}/tests/conftest.py +0 -0
  66. {arrayview-0.43.2 → arrayview-0.43.3}/tests/lifecycle_matrix.py +0 -0
  67. {arrayview-0.43.2 → arrayview-0.43.3}/tests/make_vectorfield_test_arrays.py +0 -0
  68. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_1d_arrays.py +0 -0
  69. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_backend_shared.py +0 -0
  70. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_cli.py +0 -0
  71. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_colorbar_hover_highlight.py +0 -0
  72. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_config.py +0 -0
  73. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_dicom.py +0 -0
  74. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_display_opener_results.py +0 -0
  75. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_doctor_commands.py +0 -0
  76. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_drop_import.py +0 -0
  77. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_instance_registry.py +0 -0
  78. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_invocation_contract_matrix.py +0 -0
  79. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_io.py +0 -0
  80. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_large_arrays.py +0 -0
  81. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_launch_blackbox.py +0 -0
  82. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_launch_diagnostics.py +0 -0
  83. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_launch_execution_ownership.py +0 -0
  84. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_launch_plan.py +0 -0
  85. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_launch_trace.py +0 -0
  86. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_loading_server.py +0 -0
  87. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_mode_consistency.py +0 -0
  88. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_mode_matrix.py +0 -0
  89. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_mode_roundtrip.py +0 -0
  90. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_nifti_meta.py +0 -0
  91. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_nifti_series.py +0 -0
  92. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_preferences_ui.py +0 -0
  93. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_public_invocation_regressions.py +0 -0
  94. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_render_failure_reporting.py +0 -0
  95. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_render_progress.py +0 -0
  96. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_rgb_pixel_art.py +0 -0
  97. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_safe_server_stop.py +0 -0
  98. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_server_identity_status.py +0 -0
  99. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_session_spec.py +0 -0
  100. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_source_safety.py +0 -0
  101. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_spawn_ownership.py +0 -0
  102. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_torch.py +0 -0
  103. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_tutorial.py +0 -0
  104. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_tutorial_browser.py +0 -0
  105. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_value_formatting.py +0 -0
  106. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_view_handle_lifecycle.py +0 -0
  107. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_view_launch_execution.py +0 -0
  108. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_vscode_ack_protocol.py +0 -0
  109. {arrayview-0.43.2 → arrayview-0.43.3}/tests/test_window_level_preview.py +0 -0
  110. {arrayview-0.43.2 → arrayview-0.43.3}/tests/ui_audit.py +0 -0
  111. {arrayview-0.43.2 → arrayview-0.43.3}/tests/v_anim_frames/.gitkeep +0 -0
  112. {arrayview-0.43.2 → arrayview-0.43.3}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arrayview
3
- Version: 0.43.2
3
+ Version: 0.43.3
4
4
  Summary: Fast multi-dimensional array viewer
5
5
  Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
6
  Project-URL: Source, https://github.com/oscarvanderheide/arrayview
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.43.2"
7
+ version = "0.43.3"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -51,6 +51,7 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
51
51
  if not token or len(token) > 256 or not window_id:
52
52
  raise HTTPException(status_code=400, detail="Incomplete viewer phase identity")
53
53
  allowed = {
54
+ "navigation-arrived",
54
55
  "script-loaded",
55
56
  "ws-open",
56
57
  "metadata-loaded",
@@ -58,13 +59,16 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
58
59
  }
59
60
  if phase != "launch-prepared" and phase not in allowed:
60
61
  raise HTTPException(status_code=400, detail="Invalid viewer phase")
61
- if phase != "launch-prepared" and not viewer_instance_id:
62
+ if (
63
+ phase not in {"launch-prepared", "navigation-arrived"}
64
+ and not viewer_instance_id
65
+ ):
62
66
  raise HTTPException(status_code=400, detail="Incomplete viewer phase identity")
63
67
  # Preparation and script startup are control-plane events. The viewer
64
68
  # is meant to open while a large array is still loading, so hiding
65
69
  # either event behind Session readiness creates a false pre-script
66
70
  # timeout. Data-dependent phases still wait for the real Session.
67
- if phase in {"launch-prepared", "script-loaded"}:
71
+ if phase in {"launch-prepared", "navigation-arrived", "script-loaded"}:
68
72
  if sid not in SESSIONS and sid not in _session_mod.PENDING_SESSIONS:
69
73
  raise HTTPException(status_code=404, detail="Session not found")
70
74
  else:
@@ -207,13 +211,24 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
207
211
  raise HTTPException(status_code=409, detail="Viewer phase owner changed")
208
212
  if body.get("sid") != sid:
209
213
  raise HTTPException(status_code=409, detail="Viewer primary session changed")
210
- related_sids = _viewer_related_sids(body, sid)
211
- if journal["related_sids"] is None:
212
- journal["related_sids"] = related_sids
213
- elif journal["related_sids"] != related_sids:
214
- raise HTTPException(status_code=409, detail="Viewer related sessions changed")
215
- if viewer_instance_id not in journal["viewer_instance_ids"]:
216
- journal["viewer_instance_ids"].append(viewer_instance_id)
214
+ if phase == "navigation-arrived":
215
+ navigation_attempt = body.get("navigation_attempt")
216
+ if (
217
+ type(navigation_attempt) is not int
218
+ or navigation_attempt != journal.get("navigation_attempt")
219
+ ):
220
+ raise HTTPException(
221
+ status_code=409,
222
+ detail="Viewer navigation attempt changed",
223
+ )
224
+ else:
225
+ related_sids = _viewer_related_sids(body, sid)
226
+ if journal["related_sids"] is None:
227
+ journal["related_sids"] = related_sids
228
+ elif journal["related_sids"] != related_sids:
229
+ raise HTTPException(status_code=409, detail="Viewer related sessions changed")
230
+ if viewer_instance_id not in journal["viewer_instance_ids"]:
231
+ journal["viewer_instance_ids"].append(viewer_instance_id)
217
232
  phases = journal["phases"]
218
233
  if phase not in phases:
219
234
  phases.append(phase)
@@ -245,7 +260,7 @@ def register_query_routes(app, *, get_session_or_404, pil_image, pil_imageops) -
245
260
  "token": token,
246
261
  "phases": list(phases),
247
262
  "viewer_instance_ids": list(journal["viewer_instance_ids"]),
248
- "related_sids": list(journal["related_sids"]),
263
+ "related_sids": list(journal["related_sids"] or []),
249
264
  }
250
265
 
251
266
  @app.get("/viewer-phase/{sid}/{request_id}")
@@ -3,6 +3,55 @@
3
3
  <head>
4
4
  <title>ArrayView</title>
5
5
  <script>
6
+ // Tell the opener as soon as this document starts parsing. The full
7
+ // viewer script is large and sits near the end of this file, so using
8
+ // its startup as proof that navigation arrived can make a slow but
9
+ // valid page look dropped and cause its tab to be closed prematurely.
10
+ (function () {
11
+ try {
12
+ var launchSearch = __ARRAYVIEW_QUERY__ || location.search;
13
+ var params = new URLSearchParams(launchSearch);
14
+ if (params.get('_av_integrated_browser') !== '1') return;
15
+ var sid = params.get('sid');
16
+ var requestId = params.get('_av_launch_request_id');
17
+ var serverId = params.get('_av_launch_server_id');
18
+ var windowId = params.get('_av_launch_window_id');
19
+ var token = params.get('_av_launch_token');
20
+ var attemptValue = params.get('_av_navigation_attempt');
21
+ var attempt = Number(attemptValue);
22
+ if (
23
+ !sid || !requestId || !serverId || !windowId || !token
24
+ || attemptValue === null
25
+ || !Number.isInteger(attempt) || attempt < 0
26
+ ) return;
27
+ var phaseUrl = `/viewer-phase/${encodeURIComponent(sid)}/${encodeURIComponent(requestId)}`;
28
+ var phaseBody = JSON.stringify({
29
+ phase: 'navigation-arrived',
30
+ sid,
31
+ server_id: serverId,
32
+ window_id: windowId,
33
+ token,
34
+ navigation_attempt: attempt,
35
+ });
36
+ var reportArrival = function (retriesLeft) {
37
+ fetch(phaseUrl, {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: phaseBody,
41
+ }).then(function (response) {
42
+ if (!response.ok) throw new Error('arrival rejected');
43
+ }).catch(function () {
44
+ if (retriesLeft > 0) {
45
+ setTimeout(function () {
46
+ reportArrival(retriesLeft - 1);
47
+ }, 100);
48
+ }
49
+ });
50
+ };
51
+ reportArrival(3);
52
+ } catch (_) {}
53
+ })();
54
+
6
55
  // Name the tab at parse time. Without this the tab shows the host
7
56
  // ("localhost:8000") until metadata arrives over the WebSocket, and
8
57
  // that wait is the array load — seconds for a large file. The name
@@ -2332,7 +2381,7 @@
2332
2381
  #info-box { padding: 18px 22px; font-size: 12px; line-height: 1.8; font-family: 'SF Mono', ui-monospace, monospace; }
2333
2382
  #info-box table { border-collapse: collapse; width: 100%; }
2334
2383
  #info-box td:first-child { color: var(--muted); padding-right: 24px; }
2335
- #help-hint, #tool-hint, #qmri-hint {
2384
+ #help-hint, #tool-hint, #qmri-hint, #ras-hint {
2336
2385
  position: fixed; bottom: 14px; cursor: pointer;
2337
2386
  width: 22px; height: 22px; border-radius: 50%;
2338
2387
  border: 1px solid var(--border); color: var(--muted);
@@ -2348,7 +2397,25 @@
2348
2397
  #qmri-hint.visible { display: flex; }
2349
2398
  #qmri-hint .q-dim-icon { width: 17px; height: 17px; }
2350
2399
  #tool-hint.active { color: var(--active-dim); border-color: var(--active-dim); }
2351
- #help-hint:hover, #tool-hint:hover, #qmri-hint:hover { color: var(--text); border-color: var(--muted); }
2400
+ #help-hint:hover, #tool-hint:hover, #qmri-hint:hover, #ras-hint:hover { color: var(--text); border-color: var(--muted); }
2401
+ #ras-hint {
2402
+ right: 106px; display: none; position: fixed;
2403
+ font-family: 'SF Mono', ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace;
2404
+ font-size: 8px; font-weight: 600; letter-spacing: -0.3px;
2405
+ }
2406
+ #ras-hint.visible { display: flex; }
2407
+ #ras-hint.active { color: var(--active-dim); border-color: var(--active-dim); }
2408
+ #ras-hint.loading { border-color: transparent; }
2409
+ #ras-hint.loading::before {
2410
+ content: ''; position: absolute; inset: -1px; border-radius: 50%;
2411
+ padding: 1px;
2412
+ background: conic-gradient(var(--active-dim) 0deg, var(--active-dim) 100deg, transparent 100deg, transparent 360deg);
2413
+ -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
2414
+ -webkit-mask-composite: xor;
2415
+ mask-composite: exclude;
2416
+ animation: ras-hint-spin 0.85s linear infinite;
2417
+ }
2418
+ @keyframes ras-hint-spin { to { transform: rotate(360deg); } }
2352
2419
  #dynamic-island {
2353
2420
  position: fixed; left: 8px; top: 8px;
2354
2421
  background: rgba(24,24,24,0.78); border: 1px solid rgba(255,255,255,0.07);
@@ -4502,7 +4569,7 @@
4502
4569
  /* ── Zen / focus mode ────────────────────────────────────── */
4503
4570
  body.zen-mode #array-name, body.zen-mode #info,
4504
4571
  body.zen-mode #slim-cb-wrap, body.zen-mode #mode-eggs,
4505
- body.zen-mode #help-hint, body.zen-mode #tool-hint, body.zen-mode #status,
4572
+ body.zen-mode #help-hint, body.zen-mode #tool-hint, body.zen-mode #ras-hint, body.zen-mode #status,
4506
4573
  body.zen-mode #toast, body.zen-mode #dim-active-underline,
4507
4574
  body.zen-mode #overlay-palette { opacity:0 !important; transition:opacity 0.6s ease; pointer-events:none !important; }
4508
4575
  body.zen-mode .qv-cb-island,
@@ -5503,6 +5570,7 @@
5503
5570
  <div id="tool-hint" title="Tool launcher (/)">/</div>
5504
5571
  <div id="preferences-hint" title="Preferences" aria-label="Preferences"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34 1.7 1.7 0 0 0-1.03 1.56V21h-4v-.08A1.7 1.7 0 0 0 9 19.37a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.63 15 1.7 1.7 0 0 0 3.08 14H3v-4h.08A1.7 1.7 0 0 0 4.63 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.63 1.7 1.7 0 0 0 10 3.08V3h4v.08A1.7 1.7 0 0 0 15 4.63a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.37 9 1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15z"></path></svg></div>
5505
5572
  <div id="qmri-hint" title="qMRI options"></div>
5573
+ <div id="ras-hint" title="Convert to RAS orientation — aligns axes so every view scrolls fast, not just the native one (Shift+A)">RAS</div>
5506
5574
  <canvas id="mv-orientation"></canvas>
5507
5575
  <div id="dynamic-island"></div>
5508
5576
  <div id="tool-launcher" aria-hidden="true"></div>
@@ -6458,7 +6526,12 @@
6458
6526
  let isRendering = false;
6459
6527
  let pendingRequest = false;
6460
6528
  let wsSlowTimer = null; // show the busy panel for slow renders
6461
- const WS_SLOW_THRESHOLD_MS = 80;
6529
+ // Deliberately much higher than PATIENT_LOADING_THRESHOLD_MS: this one
6530
+ // fires for every render, including plain in-array scrolling, so on a
6531
+ // slow network drive a short threshold flickers on/off as each frame
6532
+ // just barely crosses it. This should only catch renders that are
6533
+ // actually stalled, not ordinary slow-drive catch-up.
6534
+ const WS_SLOW_THRESHOLD_MS = 500;
6462
6535
  const PATIENT_LOADING_THRESHOLD_MS = 80;
6463
6536
  let _displayedPatientIndex = null;
6464
6537
  let _patientLoadingIndex = null;
@@ -6731,6 +6804,7 @@
6731
6804
  let excludeZeros = false; // exclude zero-valued voxels from histogram computation
6732
6805
  let spatialMeta = null; // {voxel_sizes, axis_labels, is_oblique, is_canonical} or null
6733
6806
  let rasResampleActive = false;
6807
+ let _rasResampleLoading = false;
6734
6808
  let multiViewActive = false;
6735
6809
  let _mvOrientationHidden = false; // ~ toggle — hides orientation cube
6736
6810
  let mipActive = false;
@@ -24421,6 +24495,7 @@
24421
24495
  title: 'Toggle explicit RAS conversion (NIfTI)',
24422
24496
  when: [],
24423
24497
  run: (ctx, e) => {
24498
+ if (_rasResampleLoading) return;
24424
24499
  if (compareActive || qmriActive) {
24425
24500
  showToast('RAS resample not supported in compare/qMRI mode');
24426
24501
  return;
@@ -24436,13 +24511,15 @@
24436
24511
  }
24437
24512
  const wantOn = !rasResampleActive;
24438
24513
  showToast(wantOn ? 'converting to RAS...' : 'reverting to native orientation...');
24514
+ _rasResampleLoading = true;
24515
+ document.getElementById('ras-hint')?.classList.add('loading');
24439
24516
  proxyFetch(`/resample_ras/${sid}`, {
24440
24517
  method: 'POST',
24441
24518
  headers: {'Content-Type': 'application/json'},
24442
24519
  body: JSON.stringify({ enabled: wantOn }),
24443
24520
  }).then(r => r.json()).then(d => {
24444
- if (d.error) { showToast('RAS resample error: ' + d.error); return; }
24445
- if (d.skipped) { showToast('RAS resample skipped: ' + d.reason); return; }
24521
+ if (d.error) { _clearRasHintLoading(); showToast('RAS resample error: ' + d.error); return; }
24522
+ if (d.skipped) { _clearRasHintLoading(); showToast('RAS resample skipped: ' + d.reason); return; }
24446
24523
  rasResampleActive = wantOn;
24447
24524
  // Refresh shape from /info, then re-center indices and refresh
24448
24525
  // canvases. Because shape changes (e.g. (240,240,155) ->
@@ -24475,9 +24552,12 @@
24475
24552
  updateView(); triggerPreload();
24476
24553
  }
24477
24554
  renderInfo();
24555
+ _reconcileUI();
24556
+ _clearRasHintLoading();
24478
24557
  });
24479
24558
  showToast('RAS resample: ' + (wantOn ? 'on' : 'off') + (d.elapsed_ms ? ` (${d.elapsed_ms}ms)` : ''));
24480
24559
  }).catch(err => {
24560
+ _clearRasHintLoading();
24481
24561
  showToast('RAS resample failed: ' + err);
24482
24562
  });
24483
24563
  },
@@ -27298,6 +27378,11 @@
27298
27378
  if (cmd && typeof cmd.run === 'function') cmd.run(makeContext(null), null);
27299
27379
  }
27300
27380
 
27381
+ function _clearRasHintLoading() {
27382
+ _rasResampleLoading = false;
27383
+ document.getElementById('ras-hint')?.classList.remove('loading');
27384
+ }
27385
+
27301
27386
  function _saveFullArrayNpy() {
27302
27387
  if (_inPyWebView) {
27303
27388
  proxyFetch(`/export_array/${sid}?save_to_downloads=1`)
@@ -35596,6 +35681,9 @@
35596
35681
  document.getElementById('tool-hint').addEventListener('click', () => {
35597
35682
  openToolMenu();
35598
35683
  });
35684
+ document.getElementById('ras-hint').addEventListener('click', () => {
35685
+ _runCommand('nifti.toggleRasResample');
35686
+ });
35599
35687
  document.getElementById('tool-launcher').addEventListener('click', (e) => {
35600
35688
  if (e.target === e.currentTarget) closeToolLauncher();
35601
35689
  });
@@ -37357,6 +37445,15 @@
37357
37445
  if (!qmriHintActive) _qmriOptionsPinned = false;
37358
37446
  _reconcileQmriAltOptions();
37359
37447
  }
37448
+
37449
+ // 8. RAS conversion hint — visible whenever the loaded array carries
37450
+ // NIfTI spatial metadata (single .nii/.nii.gz sessions).
37451
+ const rasHint = document.getElementById('ras-hint');
37452
+ if (rasHint) {
37453
+ rasHint.classList.toggle('visible', !!spatialMeta);
37454
+ rasHint.classList.toggle('active', rasResampleActive);
37455
+ rasHint.setAttribute('aria-hidden', spatialMeta ? 'false' : 'true');
37456
+ }
37360
37457
  _updateMvPaneFill();
37361
37458
  }
37362
37459
 
@@ -38503,7 +38600,7 @@
38503
38600
  },
38504
38601
  chromeToFade: () => {
38505
38602
  const sel = ['#mv-cb-wrap', '#array-name', '#mv-orientation',
38506
- '#help-hint', '#tool-hint',
38603
+ '#help-hint', '#tool-hint', '#ras-hint',
38507
38604
  '.cb-island', '.cv-pixel-info', '.axes-indicator', '.mv-label'];
38508
38605
  const set = new Set();
38509
38606
  sel.forEach(s => document.querySelectorAll(s).forEach(el => set.add(el)));
@@ -39306,7 +39403,13 @@
39306
39403
  return true;
39307
39404
  }
39308
39405
 
39309
- function _dmenuCyclePresetFromKey() {
39406
+ async function _dmenuCyclePresetFromKey() {
39407
+ // In qMRI mode each pane owns its own window, so once the picker
39408
+ // is already open, repeat `d` must keep following the mouse
39409
+ // instead of falling back to the single shared primary path.
39410
+ if ((qmriActive || compareQmriActive) && _hoveredQmriView) {
39411
+ return await _cycleQmriPaneRangePreset();
39412
+ }
39310
39413
  const preset = _cycleQuantilePreset();
39311
39414
  if (preset) {
39312
39415
  _dmenuSelectCurrentPreset();
@@ -10,6 +10,7 @@ import signal
10
10
  import subprocess
11
11
  import time
12
12
  import zipfile
13
+ from contextlib import contextmanager
13
14
  from importlib.resources import files as _pkg_files
14
15
 
15
16
  from arrayview._session import _vprint
@@ -57,13 +58,85 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
57
58
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
58
59
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
59
60
  _VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
60
- _VSCODE_EXT_VERSION = "0.15.48" # current bundled extension version
61
+ _VSCODE_EXT_VERSION = "0.15.53" # current bundled extension version
61
62
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
62
63
  # Version-skew notices already printed, so a long-lived process (Jupyter, a
63
64
  # script opening several arrays) states the mismatch once instead of per call.
64
65
  _VSCODE_EXT_SKEW_REPORTED: set[tuple] = set()
65
66
  _VSCODE_EXT_INSTALL_GUARD_PREFIX = "vscode-extension-install-failure-"
66
67
  _VSCODE_EXT_INSTALL_NO_HOST_COOLDOWN_SECONDS = 300.0
68
+ _VSCODE_EXT_INSTALL_LOCK_TIMEOUT_SECONDS = 60.0
69
+ _VSCODE_EXT_INSTALL_LOCK_STALE_SECONDS = 90.0
70
+
71
+
72
+ def _vscode_extension_install_lock_path() -> str:
73
+ return os.path.join(
74
+ os.path.expanduser("~/.arrayview"), "vscode-extension-install.lock"
75
+ )
76
+
77
+
78
+ @contextmanager
79
+ def _vscode_extension_install_lock(
80
+ *, timeout: float = _VSCODE_EXT_INSTALL_LOCK_TIMEOUT_SECONDS
81
+ ):
82
+ """Serialize the rare shared-profile install transaction across processes."""
83
+ path = _vscode_extension_install_lock_path()
84
+ token = f"{os.getpid()}-{time.time_ns()}"
85
+ deadline = time.monotonic() + timeout
86
+ acquired = False
87
+ os.makedirs(os.path.dirname(path), exist_ok=True)
88
+ while True:
89
+ try:
90
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
91
+ with os.fdopen(fd, "w") as handle:
92
+ json.dump(
93
+ {"pid": os.getpid(), "token": token, "createdAt": time.time()},
94
+ handle,
95
+ )
96
+ acquired = True
97
+ break
98
+ except FileExistsError:
99
+ try:
100
+ with open(path) as handle:
101
+ owner = json.load(handle)
102
+ owner_pid = owner.get("pid")
103
+ created_at = owner.get("createdAt")
104
+ # A live installer keeps ownership however long VS Code takes.
105
+ # Age alone must not permit a second process to install into the
106
+ # same shared registry concurrently.
107
+ stale = not _process_is_alive(owner_pid)
108
+ except (OSError, ValueError, TypeError, AttributeError):
109
+ # Recover a lock whose owner died between the exclusive create
110
+ # and writing valid JSON, but do not race an in-progress write.
111
+ try:
112
+ stale = (
113
+ time.time() - os.stat(path).st_mtime
114
+ > _VSCODE_EXT_INSTALL_LOCK_STALE_SECONDS
115
+ )
116
+ except OSError:
117
+ stale = False
118
+ if stale:
119
+ try:
120
+ os.unlink(path)
121
+ except OSError:
122
+ pass
123
+ continue
124
+ if time.monotonic() >= deadline:
125
+ break
126
+ time.sleep(0.1)
127
+ except OSError:
128
+ break
129
+ try:
130
+ yield acquired
131
+ finally:
132
+ if acquired:
133
+ try:
134
+ with open(path) as handle:
135
+ owner = json.load(handle)
136
+ if owner.get("token") == token:
137
+ os.unlink(path)
138
+ except (OSError, ValueError, TypeError, AttributeError):
139
+ pass
67
140
 
68
141
 
69
142
  def _extension_install_guard_key(
@@ -256,6 +329,62 @@ def _extension_bases(*, remote: bool | None = None) -> tuple[str, ...]:
256
329
  return (local, remote_base)
257
330
 
258
331
 
332
+ def _extension_registered(base: str, version: str) -> bool:
333
+ """Return whether one extension profile registers this exact installation."""
334
+ registry = os.path.join(base, "extensions.json")
335
+ expected = os.path.realpath(
336
+ os.path.join(base, f"arrayview.arrayview-opener-{version}")
337
+ )
338
+ try:
339
+ with open(registry) as handle:
340
+ entries = json.load(handle)
341
+ except (OSError, ValueError, TypeError):
342
+ return False
343
+ if not isinstance(entries, list):
344
+ return False
345
+ for entry in entries:
346
+ if not isinstance(entry, dict) or entry.get("version") != version:
347
+ continue
348
+ identifier = entry.get("identifier")
349
+ if not isinstance(identifier, dict) or (
350
+ str(identifier.get("id", "")).lower()
351
+ != "arrayview.arrayview-opener"
352
+ ):
353
+ continue
354
+ locations: list[str] = []
355
+ relative = entry.get("relativeLocation")
356
+ if isinstance(relative, str):
357
+ locations.append(os.path.join(base, relative))
358
+ location = entry.get("location")
359
+ if isinstance(location, dict):
360
+ for field in ("fsPath", "path"):
361
+ value = location.get(field)
362
+ if isinstance(value, str):
363
+ locations.append(value)
364
+ if any(os.path.realpath(path) == expected for path in locations):
365
+ return True
366
+ return False
367
+
368
+
369
+ def _wait_for_extension_registration(
370
+ base: str, version: str, *, timeout: float = 5.0
371
+ ) -> bool:
372
+ """Wait for VS Code to commit an install to the selected profile registry."""
373
+ deadline = time.monotonic() + timeout
374
+ while time.monotonic() < deadline:
375
+ if _extension_registered(base, version):
376
+ return True
377
+ time.sleep(0.1)
378
+ return _extension_registered(base, version)
379
+
380
+
381
+ def _extension_directory_present(version: str, *, remote: bool) -> bool:
382
+ return any(
383
+ os.path.isdir(os.path.join(base, f"arrayview.arrayview-opener-{version}"))
384
+ for base in _extension_bases(remote=remote)
385
+ )
386
+
387
+
259
388
  def _remove_old_extension_versions(
260
389
  current_version: str, *, remote: bool | None = None
261
390
  ) -> None:
@@ -310,7 +439,7 @@ def _extension_on_disk(
310
439
  """
311
440
  for base in _extension_bases(remote=remote):
312
441
  ext_dir = os.path.join(base, f"arrayview.arrayview-opener-{version}")
313
- if not os.path.isdir(ext_dir):
442
+ if not os.path.isdir(ext_dir) or not _extension_registered(base, version):
314
443
  continue
315
444
  if vsix_path is None:
316
445
  return True
@@ -414,7 +543,11 @@ def _newer_extension_on_disk(version: str, *, remote: bool) -> str | None:
414
543
  continue
415
544
  candidate = entry[len(prefix) :]
416
545
  key = _version_tuple(candidate)
417
- if key is not None and key > wanted:
546
+ if (
547
+ key is not None
548
+ and key > wanted
549
+ and _extension_registered(base, candidate)
550
+ ):
418
551
  newer.append((key, candidate))
419
552
  return max(newer)[1] if newer else None
420
553
 
@@ -742,6 +875,14 @@ def _write_vscode_extension_hash(
742
875
  f.write(vsix_hash)
743
876
 
744
877
 
878
+ def _installed_extension_candidate(
879
+ version: str, vsix_path: str, *, remote: bool
880
+ ) -> str | None:
881
+ if _extension_on_disk(version, vsix_path, remote=remote):
882
+ return version
883
+ return _newer_extension_on_disk(version, remote=remote)
884
+
885
+
745
886
  def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
746
887
  """Verify or install the bundled opener for the current VS Code window.
747
888
 
@@ -813,7 +954,10 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
813
954
  )
814
955
  return False
815
956
 
816
- if _extension_on_disk(ext_version, vsix_path, remote=is_remote):
957
+ installed_version = _installed_extension_candidate(
958
+ ext_version, vsix_path, remote=is_remote
959
+ )
960
+ if installed_version == ext_version:
817
961
  active_matches = (
818
962
  active_version == ext_version
819
963
  if is_remote
@@ -830,7 +974,7 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
830
974
  )
831
975
  return True
832
976
 
833
- newer_version = _newer_extension_on_disk(ext_version, remote=is_remote)
977
+ newer_version = installed_version if installed_version != ext_version else None
834
978
  if newer_version is not None:
835
979
  active_matches = (
836
980
  active_version == newer_version
@@ -851,6 +995,72 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
851
995
  if is_remote:
852
996
  _VSCODE_EXT_INSTALL_FAILED = True
853
997
  return False
998
+
999
+ with _vscode_extension_install_lock() as acquired:
1000
+ if not acquired:
1001
+ _VSCODE_EXT_INSTALL_FAILED = True
1002
+ print(
1003
+ "[ArrayView] timed out waiting for another VS Code opener install; "
1004
+ "no viewer request was sent",
1005
+ flush=True,
1006
+ )
1007
+ return False
1008
+
1009
+ # Another ArrayView process may have completed the shared-profile install
1010
+ # while this process waited. Re-read both disk/registry and live-host state
1011
+ # before consulting failure guards or starting a duplicate installer.
1012
+ installed_version = _installed_extension_candidate(
1013
+ ext_version, vsix_path, remote=is_remote
1014
+ )
1015
+ if installed_version is not None:
1016
+ active_registration = _active_extension_registration()
1017
+ active_version = (
1018
+ active_registration.get("extensionVersion")
1019
+ if active_registration is not None
1020
+ else None
1021
+ )
1022
+ active_marker = _extension_registration_marker(active_registration)
1023
+ active_matches = (
1024
+ active_version == installed_version
1025
+ if is_remote
1026
+ else active_version in (None, installed_version)
1027
+ )
1028
+ if not active_matches:
1029
+ return _reject_unmatched_host(installed_version)
1030
+ if not is_remote and installed_version == ext_version:
1031
+ _remove_old_extension_versions(ext_version, remote=False)
1032
+ _VSCODE_EXT_INSTALLED = True
1033
+ _vprint(
1034
+ f"[ArrayView] extension v{installed_version} already installed — "
1035
+ "skipping reinstall",
1036
+ flush=True,
1037
+ )
1038
+ return True
1039
+
1040
+ return _install_vscode_extension_locked(
1041
+ code=code,
1042
+ env_ipc=_find_vscode_ipc_hook(),
1043
+ ext_version=ext_version,
1044
+ vsix_path=vsix_path,
1045
+ is_remote=is_remote,
1046
+ active_version=active_version,
1047
+ active_marker=active_marker,
1048
+ )
1049
+
1050
+
1051
+ def _install_vscode_extension_locked(
1052
+ *,
1053
+ code: str,
1054
+ env_ipc: str | None,
1055
+ ext_version: str,
1056
+ vsix_path: str,
1057
+ is_remote: bool,
1058
+ active_version: str | None,
1059
+ active_marker: tuple | None,
1060
+ ) -> bool:
1061
+ """Run and verify one install while the shared-profile lock is held."""
1062
+ global _VSCODE_EXT_INSTALLED, _VSCODE_EXT_FRESH_INSTALL
1063
+ global _VSCODE_EXT_RELOAD_REQUIRED, _VSCODE_EXT_INSTALL_FAILED
854
1064
  install_guard = _extension_install_failure_guard(
855
1065
  ext_version, code, active_marker
856
1066
  )
@@ -870,11 +1080,13 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
870
1080
  return False
871
1081
 
872
1082
  env = dict(os.environ)
873
- ipc = _find_vscode_ipc_hook()
874
- if ipc:
875
- env["VSCODE_IPC_HOOK_CLI"] = ipc
1083
+ if env_ipc:
1084
+ env["VSCODE_IPC_HOOK_CLI"] = env_ipc
876
1085
 
877
1086
  install_snapshot = _extension_base_snapshot() if is_remote else {}
1087
+ orphaned_install = _extension_directory_present(
1088
+ ext_version, remote=is_remote
1089
+ )
878
1090
  # Say so before the slow part, not after it. Installing the opener takes
879
1091
  # several seconds and may end in "reload this window once"; announcing that
880
1092
  # only in the final error message left the terminal looking hung, with the
@@ -885,8 +1097,11 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
885
1097
  flush=True,
886
1098
  )
887
1099
  try:
1100
+ command = [code, "--install-extension", vsix_path]
1101
+ if orphaned_install:
1102
+ command.append("--force")
888
1103
  r = _run_extension_installer(
889
- [code, "--install-extension", vsix_path], env
1104
+ command, env
890
1105
  )
891
1106
  combined = (r.stdout or "") + (r.stderr or "")
892
1107
  install_failed = (
@@ -921,6 +1136,23 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
921
1136
  install_bases = (active_base,)
922
1137
  else:
923
1138
  install_bases = _extension_bases(remote=False)
1139
+ registered_bases = tuple(
1140
+ base
1141
+ for base in install_bases
1142
+ if _wait_for_extension_registration(base, ext_version)
1143
+ )
1144
+ if not registered_bases:
1145
+ _VSCODE_EXT_INSTALL_FAILED = True
1146
+ message = (
1147
+ "the installer extracted the opener but did not register it "
1148
+ "in the selected VS Code profile"
1149
+ )
1150
+ _write_extension_install_failure_guard(
1151
+ ext_version, code, active_marker, message
1152
+ )
1153
+ print(f"[ArrayView] extension install incomplete: {message}", flush=True)
1154
+ return False
1155
+ install_bases = registered_bases
924
1156
  _patch_vscode_extension_metadata(ext_version, bases=install_bases)
925
1157
  try:
926
1158
  _write_vscode_extension_hash(