arrayview 0.37.1__tar.gz → 0.39.0__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 (107) hide show
  1. {arrayview-0.37.1 → arrayview-0.39.0}/PKG-INFO +1 -1
  2. {arrayview-0.37.1 → arrayview-0.39.0}/pyproject.toml +1 -2
  3. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_io.py +27 -3
  4. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_launch_plan.py +0 -1
  5. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_launcher.py +20 -5
  6. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_platform.py +70 -5
  7. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_preferences.py +4 -1
  8. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_query.py +1 -0
  9. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_viewer.html +101 -9
  10. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_vscode_browser.py +6 -0
  11. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_vscode_extension.py +36 -15
  12. arrayview-0.39.0/src/arrayview/arrayview-opener.vsix +0 -0
  13. {arrayview-0.37.1 → arrayview-0.39.0}/tests/conftest.py +23 -0
  14. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_api.py +138 -3
  15. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_invocation_contract_matrix.py +0 -4
  16. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_launch_blackbox.py +3 -1
  17. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_launch_plan.py +0 -1
  18. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_lifecycle_contract.py +23 -2
  19. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_rgb_pixel_art.py +23 -7
  20. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_view_launch_execution.py +5 -0
  21. arrayview-0.37.1/src/arrayview/_codex_open.py +0 -137
  22. arrayview-0.37.1/src/arrayview/arrayview-opener.vsix +0 -0
  23. arrayview-0.37.1/tests/test_codex_open.py +0 -160
  24. {arrayview-0.37.1 → arrayview-0.39.0}/.gitignore +0 -0
  25. {arrayview-0.37.1 → arrayview-0.39.0}/LICENSE +0 -0
  26. {arrayview-0.37.1 → arrayview-0.39.0}/README.md +0 -0
  27. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/ARCHITECTURE.md +0 -0
  28. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/__init__.py +0 -0
  29. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/__main__.py +0 -0
  30. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_analysis.py +0 -0
  31. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_app.py +0 -0
  32. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_config.py +0 -0
  33. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_dicom.py +0 -0
  34. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_diff.py +0 -0
  35. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_icon.png +0 -0
  36. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_imaging.py +0 -0
  37. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_instance_registry.py +0 -0
  38. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_launch_trace.py +0 -0
  39. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_lifecycle.py +0 -0
  40. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_overlays.py +0 -0
  41. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_render.py +0 -0
  42. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_analysis.py +0 -0
  43. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_drop.py +0 -0
  44. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_export.py +0 -0
  45. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_loading.py +0 -0
  46. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_persistence.py +0 -0
  47. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_preload.py +0 -0
  48. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_rendering.py +0 -0
  49. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_segmentation.py +0 -0
  50. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_state.py +0 -0
  51. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_vectorfield.py +0 -0
  52. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_routes_websocket.py +0 -0
  53. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_segmentation.py +0 -0
  54. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_server.py +0 -0
  55. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_session.py +0 -0
  56. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_session_spec.py +0 -0
  57. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_shell.html +0 -0
  58. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_synthetic_mri.py +0 -0
  59. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_torch.py +0 -0
  60. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_vectorfield.py +0 -0
  61. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_vscode.py +0 -0
  62. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/_vscode_signal.py +0 -0
  63. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/gsap.min.js +0 -0
  64. {arrayview-0.37.1 → arrayview-0.39.0}/src/arrayview/todo.md +0 -0
  65. {arrayview-0.37.1 → arrayview-0.39.0}/tests/capture_patient_loading.py +0 -0
  66. {arrayview-0.37.1 → arrayview-0.39.0}/tests/capture_v_animation.py +0 -0
  67. {arrayview-0.37.1 → arrayview-0.39.0}/tests/lifecycle_matrix.py +0 -0
  68. {arrayview-0.37.1 → arrayview-0.39.0}/tests/make_vectorfield_test_arrays.py +0 -0
  69. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_backend_shared.py +0 -0
  70. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_browser.py +0 -0
  71. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_cli.py +0 -0
  72. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_colorbar_hover_highlight.py +0 -0
  73. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_command_reachability.py +0 -0
  74. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_config.py +0 -0
  75. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_cross_mode_parametrized.py +0 -0
  76. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_dicom.py +0 -0
  77. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_display_opener_results.py +0 -0
  78. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_doctor_commands.py +0 -0
  79. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_drop_import.py +0 -0
  80. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_instance_registry.py +0 -0
  81. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_interactions.py +0 -0
  82. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_io.py +0 -0
  83. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_large_arrays.py +0 -0
  84. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_launch_diagnostics.py +0 -0
  85. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_launch_execution_ownership.py +0 -0
  86. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_launch_trace.py +0 -0
  87. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_loading_server.py +0 -0
  88. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_mode_consistency.py +0 -0
  89. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_mode_matrix.py +0 -0
  90. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_mode_roundtrip.py +0 -0
  91. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_nifti_meta.py +0 -0
  92. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_nifti_series.py +0 -0
  93. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_preferences_ui.py +0 -0
  94. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_public_invocation_regressions.py +0 -0
  95. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_safe_server_stop.py +0 -0
  96. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_server_identity_status.py +0 -0
  97. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_session_spec.py +0 -0
  98. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_spawn_ownership.py +0 -0
  99. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_torch.py +0 -0
  100. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_value_formatting.py +0 -0
  101. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_view_component_integration.py +0 -0
  102. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_view_component_unit.py +0 -0
  103. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_view_handle_lifecycle.py +0 -0
  104. {arrayview-0.37.1 → arrayview-0.39.0}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.37.1 → arrayview-0.39.0}/tests/ui_audit.py +0 -0
  106. {arrayview-0.37.1 → arrayview-0.39.0}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.37.1 → arrayview-0.39.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.37.1
3
+ Version: 0.39.0
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.37.1"
7
+ version = "0.39.0"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -43,7 +43,6 @@ dependencies = [
43
43
 
44
44
  [project.scripts]
45
45
  arrayview = "arrayview._launcher:arrayview"
46
- arrayview-codex = "arrayview._codex_open:main"
47
46
 
48
47
  [tool.hatch.build.targets.wheel]
49
48
  packages = ["src/arrayview"]
@@ -1304,6 +1304,8 @@ def _load_file_series(path, *, load="lazy", stack="auto"):
1304
1304
  Returns ``(series, spatial_meta)``.
1305
1305
  """
1306
1306
  # ── collect files by parent directory ──────────────────────────
1307
+ from ._dicom import is_dicom_source
1308
+
1307
1309
  patients: dict[str, list[str]] = {}
1308
1310
  all_nifti = True
1309
1311
 
@@ -1312,6 +1314,14 @@ def _load_file_series(path, *, load="lazy", stack="auto"):
1312
1314
  os.path.join(root, f)
1313
1315
  for f in files
1314
1316
  if _get_ext(f) in _SUPPORTED_EXTS
1317
+ # ".dcm" is necessary but not sufficient — every other .dcm
1318
+ # consumer (load_data, load_data_with_meta, _peek_file_shape's
1319
+ # directory branch) validates via is_dicom_source before
1320
+ # treating a file as real DICOM. Do the same here, otherwise a
1321
+ # stray file that merely has a .dcm extension gets counted as a
1322
+ # patient's series member and blows up load_data() downstream
1323
+ # instead of being silently ignored like any other unsupported file.
1324
+ if _get_ext(f) != ".dcm" or is_dicom_source(os.path.join(root, f))
1315
1325
  )
1316
1326
  if supported:
1317
1327
  patients[root] = supported
@@ -1476,9 +1486,16 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
1476
1486
  """
1477
1487
  from ._dicom import is_dicom_source, load_dicom_series
1478
1488
 
1479
- if (os.path.isdir(filepath) or filepath.lower().endswith(".dcm")) and is_dicom_source(filepath):
1489
+ # A ".zarr" store is itself a directory on disk. It must be excluded from
1490
+ # the generic "directory of files to stack" path below, or the walk finds
1491
+ # nothing matching a supported single-file extension inside it (zarr's own
1492
+ # chunk/metadata files don't) and raises instead of opening the store.
1493
+ is_zarr_dir = os.path.isdir(filepath) and _get_ext(filepath) == ".zarr"
1494
+ if (
1495
+ (os.path.isdir(filepath) and not is_zarr_dir) or filepath.lower().endswith(".dcm")
1496
+ ) and is_dicom_source(filepath):
1480
1497
  return load_dicom_series(filepath, select=select)
1481
- if os.path.isdir(filepath):
1498
+ if os.path.isdir(filepath) and not is_zarr_dir:
1482
1499
  return _load_file_series(filepath, load=load, stack=stack)
1483
1500
  if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
1484
1501
  return _load_nifti_with_meta(filepath)
@@ -1491,7 +1508,7 @@ def load_data(filepath, key=None):
1491
1508
 
1492
1509
  data, _meta = load_dicom_series(filepath)
1493
1510
  return data
1494
- if os.path.isdir(filepath):
1511
+ if os.path.isdir(filepath) and _get_ext(filepath) != ".zarr":
1495
1512
  from ._dicom import is_dicom_source, load_dicom_series
1496
1513
 
1497
1514
  if is_dicom_source(filepath):
@@ -1646,6 +1663,13 @@ FULL_LOAD_EXTS = frozenset([".pt", ".pth", ".tif", ".tiff", ".mat"])
1646
1663
  def _peek_file_shape(fpath: str, ext: str):
1647
1664
  """Try to return shape quickly without loading the full array. Returns None on failure."""
1648
1665
  try:
1666
+ # ".zarr" stores are directories, but they're a single array, not a
1667
+ # collection to walk and stack — read metadata via zarr, not the
1668
+ # directory-of-files branch below.
1669
+ if ext in (".zarr", ".zarr.zip"):
1670
+ import zarr
1671
+
1672
+ return list(zarr.open(fpath, mode="r").shape)
1649
1673
  if os.path.isdir(fpath):
1650
1674
  from ._dicom import is_dicom_source, load_dicom_series
1651
1675
 
@@ -41,7 +41,6 @@ class Invocation(_StrEnum):
41
41
  JULIA = "julia"
42
42
  MATLAB = "matlab"
43
43
  VSCODE_EXPLORER = "vscode_explorer"
44
- CODEX = "codex"
45
44
 
46
45
 
47
46
  class Environment(_StrEnum):
@@ -44,6 +44,21 @@ import arrayview._platform as _platform_mod # for mutable globals
44
44
  _vscode_mod_cache = None
45
45
 
46
46
 
47
+ def _detached_child_kwargs() -> dict[str, object]:
48
+ """Popen options placing a spawned child outside our own signal reach.
49
+
50
+ ``start_new_session`` is POSIX-only, so passing ``sys.platform != "win32"``
51
+ left every Windows spawn in the parent's console process group. A console
52
+ control event raised for that group reaches *every* process attached to it,
53
+ so a child being shut down can deliver a KeyboardInterrupt to the process
54
+ that spawned it — under pytest, to the test runner itself. Both branches
55
+ state the same intent: the child gets its own group.
56
+ """
57
+ if sys.platform == "win32":
58
+ return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
59
+ return {"start_new_session": True}
60
+
61
+
47
62
  def _trace_launch_event(event: str, **attrs: object) -> None:
48
63
  """Emit an opt-in launch event without loading tracing on normal paths."""
49
64
  if not os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
@@ -495,7 +510,7 @@ def _open_webview(
495
510
  stdout=subprocess.DEVNULL,
496
511
  stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL,
497
512
  close_fds=True,
498
- start_new_session=sys.platform != "win32",
513
+ **_detached_child_kwargs(),
499
514
  )
500
515
 
501
516
  # URL mode — direct load (used when shell_port not provided)
@@ -545,7 +560,7 @@ def _open_webview(
545
560
  stdout=subprocess.DEVNULL,
546
561
  stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL,
547
562
  close_fds=True,
548
- start_new_session=sys.platform != "win32",
563
+ **_detached_child_kwargs(),
549
564
  )
550
565
 
551
566
 
@@ -2062,7 +2077,7 @@ def _handle_cli_spawned_daemon(
2062
2077
  "stdout": subprocess.DEVNULL,
2063
2078
  "stderr": subprocess.DEVNULL,
2064
2079
  "close_fds": True,
2065
- "start_new_session": sys.platform != "win32",
2080
+ **_detached_child_kwargs(),
2066
2081
  }
2067
2082
  if os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
2068
2083
  from arrayview._launch_trace import trace_child_environment
@@ -3998,7 +4013,7 @@ def _view_subprocess(
3998
4013
  stdout=subprocess.DEVNULL,
3999
4014
  stderr=subprocess.DEVNULL,
4000
4015
  close_fds=True,
4001
- start_new_session=sys.platform != "win32",
4016
+ **_detached_child_kwargs(),
4002
4017
  )
4003
4018
  if not _wait_for_spawned_server(daemon_proc, port, timeout=15.0):
4004
4019
  raise RuntimeError(
@@ -5439,7 +5454,7 @@ def arrayview():
5439
5454
  stdout=subprocess.DEVNULL,
5440
5455
  stderr=subprocess.DEVNULL,
5441
5456
  close_fds=True,
5442
- start_new_session=sys.platform != "win32",
5457
+ **_detached_child_kwargs(),
5443
5458
  )
5444
5459
  if not _wait_for_spawned_server(proc, args.port, timeout=15.0):
5445
5460
  _terminate_owned_process(proc)
@@ -6,6 +6,7 @@ import importlib.util
6
6
  import hashlib
7
7
  import json
8
8
  import os
9
+ from collections.abc import Callable
9
10
  import subprocess
10
11
  import sys
11
12
 
@@ -450,6 +451,42 @@ def _process_is_alive(pid: object) -> bool:
450
451
  return False
451
452
 
452
453
 
454
+ def _superseding_window_registration(
455
+ claimed_ids: list[str],
456
+ signal_dir: str,
457
+ read: Callable[[str], tuple[str, dict] | None],
458
+ ) -> tuple[str, dict] | None:
459
+ """Return the live window that replaced one of ``claimed_ids``, if unique.
460
+
461
+ Each registration lists the window ids it took over from, so a terminal left
462
+ holding a pre-reload id resolves to its own window rather than to a sibling
463
+ that merely shares a server root. Two live windows claiming one id would be a
464
+ contradiction, so an ambiguous answer is refused instead of guessed.
465
+ """
466
+ if not claimed_ids:
467
+ return None
468
+ wanted = set(claimed_ids)
469
+ try:
470
+ filenames = os.listdir(signal_dir)
471
+ except OSError:
472
+ return None
473
+
474
+ matches: list[tuple[str, dict]] = []
475
+ for filename in filenames:
476
+ if not filename.startswith("window-") or not filename.endswith(".json"):
477
+ continue
478
+ candidate = read(filename[len("window-") : -len(".json")])
479
+ if candidate is None:
480
+ continue
481
+ superseded = candidate[1].get("supersedes")
482
+ if not isinstance(superseded, list):
483
+ continue
484
+ if wanted.intersection(item for item in superseded if isinstance(item, str)):
485
+ matches.append(candidate)
486
+
487
+ return matches[0] if len(matches) == 1 else None
488
+
489
+
453
490
  def _exact_vscode_window_registration(
454
491
  ipc: str | None = None,
455
492
  ) -> tuple[str, dict] | None:
@@ -461,6 +498,11 @@ def _exact_vscode_window_registration(
461
498
  remote registration is unsafe. When direct window evidence is unavailable,
462
499
  match the live extension-host executable to the exact server root named by
463
500
  the terminal's NLS configuration, and only accept a unique match.
501
+
502
+ Reloading a window rotates its IPC socket and re-injects ``ARRAYVIEW_WINDOW_ID``
503
+ into new terminals only, so a terminal that predates the reload keeps naming a
504
+ window id that no longer registers. The extension records the ids it replaced,
505
+ which turns that stale name back into an exact match on the window that owns it.
464
506
  """
465
507
  signal_dir = os.path.expanduser("~/.arrayview")
466
508
 
@@ -479,16 +521,23 @@ def _exact_vscode_window_registration(
479
521
  return None
480
522
  return window_id, registration
481
523
 
524
+ claimed_ids: list[str] = []
482
525
  registration = None
483
526
  if ipc:
484
527
  window_id = hashlib.sha256(ipc.encode()).hexdigest()[:16]
485
528
  if reg := _read(window_id):
486
529
  return reg
530
+ claimed_ids.append(window_id)
487
531
  registration = reg
488
532
  if registration is None:
489
533
  window_id = os.environ.get("ARRAYVIEW_WINDOW_ID")
490
- if window_id and (reg := _read(window_id)):
491
- return reg
534
+ if window_id:
535
+ if reg := _read(window_id):
536
+ return reg
537
+ claimed_ids.append(window_id)
538
+
539
+ if reg := _superseding_window_registration(claimed_ids, signal_dir, _read):
540
+ return reg
492
541
 
493
542
  exact_cli = _current_vscode_remote_cli()
494
543
  if exact_cli is None:
@@ -532,9 +581,25 @@ def _exact_vscode_registration_remote(ipc: str) -> bool | None:
532
581
  both can exist in a local desktop window.
533
582
  """
534
583
  exact = _exact_vscode_window_registration(ipc)
535
- if exact is not None and "remoteName" in exact[1]:
536
- return exact[1].get("remoteName") is not None
537
- return None
584
+ if exact is None or "remoteName" not in exact[1]:
585
+ return None
586
+ # _exact_vscode_window_registration's ARRAYVIEW_WINDOW_ID fallback trusts
587
+ # that id's own file directly and unconditionally — other callers (signal
588
+ # targeting, extension-version checks) need it to still resolve a window
589
+ # when ipc is unavailable or has legitimately drifted (e.g. a tunnel
590
+ # re-sockets without the window itself changing). Answering "is this
591
+ # remote" is riskier: a wrong placement picks the wrong display path
592
+ # outright. So here specifically, a raw env-var match with nothing to
593
+ # corroborate it against a live, unmatched ipc is refused rather than
594
+ # trusted — fall through to the caller's other signals instead.
595
+ env_window_id = os.environ.get("ARRAYVIEW_WINDOW_ID")
596
+ if (
597
+ ipc
598
+ and exact[0] == env_window_id
599
+ and exact[0] != hashlib.sha256(ipc.encode()).hexdigest()[:16]
600
+ ):
601
+ return None
602
+ return exact[1].get("remoteName") is not None
538
603
 
539
604
 
540
605
  def _has_live_vscode_remote_registration() -> bool:
@@ -42,7 +42,10 @@ def _response_payload() -> dict:
42
42
  value for value in schema["window"][key] if value != "native"
43
43
  ]
44
44
  if vscode_remote:
45
- schema["window"]["vscode"] = ["vscode", "none"]
45
+ # Native and browser don't make sense from a headless remote host;
46
+ # "none" is an internal fallback only, hidden from every row above —
47
+ # this hardcoded replacement must keep hiding it too.
48
+ schema["window"]["vscode"] = ["vscode"]
46
49
 
47
50
  overrides = {}
48
51
  if os.environ.get("ARRAYVIEW_WINDOW", "").strip():
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import io
2
3
 
3
4
  import numpy as np
@@ -6159,6 +6159,8 @@
6159
6159
  let rangeLocked = true; // when true, d-set limits never auto-recompute on navigation
6160
6160
  let vminLocked = false; // lock lower bound independently
6161
6161
  let vmaxLocked = false; // lock upper bound independently
6162
+ let symmetricLocked = false; // tie vmin = -vmax so zero stays centred
6163
+ let _dmenuSymmetricMirroring = false; // re-entrancy guard for the mirror write
6162
6164
  let excludeZeros = false; // exclude zero-valued voxels from histogram computation
6163
6165
  let spatialMeta = null; // {voxel_sizes, axis_labels, is_oblique} or null
6164
6166
  let rasResampleActive = false;
@@ -20118,6 +20120,8 @@
20118
20120
 
20119
20121
  // ── Brightness/Contrast drag (Cmd+Shift+drag) and scroll ─────────
20120
20122
  let _bcDrag = null; // { startX, startY, initCenter, initWidth }
20123
+ let _bcPendingDrag = null; // { startX, startY } — armed plain-left press, not yet a drag
20124
+ const _BC_DRAG_DEADZONE = 5; // CSS px a press must travel before it counts as windowing
20121
20125
  let _bcScrollTimer = null; // debounce timer for scroll-based BC
20122
20126
  let _bcScrollAnchor = null; // { center, width, accumX, accumY } for scroll accumulation
20123
20127
  let _bcUpdateTimer = null; // throttle timer for server updates during drag
@@ -20183,6 +20187,12 @@
20183
20187
  startX: e.clientX, startY: e.clientY,
20184
20188
  initCenter: (vmin + vmax) / 2,
20185
20189
  initWidth: vmax - vmin,
20190
+ // A locked vmin or a symmetric lock removes a degree of
20191
+ // freedom, so the drag has to collapse to one axis — see
20192
+ // _bcUpdateDrag.
20193
+ vminLocked: _dmenuEffectiveVminLocked(),
20194
+ symmetric: _dmenuEffectiveSymmetricLocked(),
20195
+ lockedLo: vmin,
20186
20196
  };
20187
20197
  canvas.style.cursor = 'crosshair';
20188
20198
  _bcExpandHist();
@@ -20193,12 +20203,46 @@
20193
20203
  const dy = e.clientY - _bcDrag.startY;
20194
20204
  // Sensitivity: scale by initial width so drag feels proportional
20195
20205
  const sens = _bcDrag.initWidth / 300;
20196
- const newCenter = _bcDrag.initCenter - dx * sens;
20197
- const newWidth = _bcDrag.initWidth - dy * sens * 2;
20206
+ let newWidth = _bcDrag.initWidth - dy * sens * 2;
20207
+ let newCenter;
20208
+ if (_bcDrag.symmetric) {
20209
+ // Bounds are tied to ±half-width, so the centre is fixed at
20210
+ // zero and only the width is free — vertical-only, same shape
20211
+ // as the pinned-vmin case below but anchored differently.
20212
+ newWidth = Math.max(newWidth, Math.abs(_bcDrag.initWidth) * 1e-3 || 1e-9);
20213
+ newCenter = 0;
20214
+ } else if (_bcDrag.vminLocked) {
20215
+ // vmin is pinned (manual lock, qMRI role, or zero floor), so
20216
+ // vmax is the only free bound and centre/width are no longer
20217
+ // independent. The horizontal axis has nothing to move: the
20218
+ // drag collapses to vertical-only, growing the window upward
20219
+ // from the pinned vmin. Clamped so vmax can never cross it.
20220
+ newWidth = Math.max(newWidth, Math.abs(_bcDrag.initWidth) * 1e-3 || 1e-9);
20221
+ newCenter = _bcDrag.lockedLo + newWidth / 2;
20222
+ } else {
20223
+ newCenter = _bcDrag.initCenter - dx * sens;
20224
+ }
20198
20225
  _bcApplyWindow(newCenter, newWidth);
20199
20226
  _bcThrottledUpdate();
20200
20227
  }
20228
+ // A plain left press arms a pending drag rather than starting one, so a
20229
+ // press that never travels stays a click-to-copy / pixel-info hold.
20230
+ // Past the deadzone it becomes window/level — the PACS convention for a
20231
+ // single 2D view. Ortho and the other multi-view panes have their own
20232
+ // canvases and keep left-drag for crosshair navigation.
20233
+ function _bcMaybePromotePending(e) {
20234
+ if (!_bcPendingDrag || _bcDrag) return;
20235
+ if (!(e.buttons & 1)) { _bcPendingDrag = null; return; }
20236
+ const origin = _bcPendingDrag;
20237
+ if (Math.hypot(e.clientX - origin.startX, e.clientY - origin.startY) < _BC_DRAG_DEADZONE) return;
20238
+ _bcPendingDrag = null;
20239
+ if (_pixelInfoHeld) { _pixelInfoHeld = false; hideAllPixelInfo(); }
20240
+ // Anchor to the press origin, not to here, so the window doesn't
20241
+ // jump by the deadzone distance on the first frame.
20242
+ _bcStartDrag({ clientX: origin.startX, clientY: origin.startY });
20243
+ }
20201
20244
  function _bcEndDrag() {
20245
+ _bcPendingDrag = null;
20202
20246
  if (!_bcDrag) return;
20203
20247
  _bcDrag = null;
20204
20248
  canvas.style.cursor = '';
@@ -20321,6 +20365,7 @@
20321
20365
  if (!rectRoiMode && e.button === 0 && !e.shiftKey && !e.metaKey && !e.altKey
20322
20366
  && !_cropActive && !_segMode && !_rulerMode) {
20323
20367
  _pixelInfoHeld = true;
20368
+ if (_bcCanDrag()) _bcPendingDrag = { startX: e.clientX, startY: e.clientY };
20324
20369
  canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: e.clientX, clientY: e.clientY, buttons: 1, ctrlKey: e.ctrlKey, bubbles: true }));
20325
20370
  setTimeout(() => {
20326
20371
  if (!_pixelInfoHeld) return;
@@ -21437,7 +21482,7 @@
21437
21482
  }
21438
21483
  // Old export-btn and roi-content listeners removed — now handled by island actions
21439
21484
  // Brightness/contrast: mousemove updates vmin/vmax
21440
- canvas.addEventListener('mousemove', e => { _bcUpdateDrag(e); });
21485
+ canvas.addEventListener('mousemove', e => { _bcMaybePromotePending(e); _bcUpdateDrag(e); });
21441
21486
  // Crop drag-handle: while dragging, update the matching edge; otherwise
21442
21487
  // provide hover-cursor feedback when near an edge. Runs even when
21443
21488
  // Alt is held so we still update during an accidental Alt hover,
@@ -34766,6 +34811,17 @@
34766
34811
  return vminLocked || _dmenuPolicyLocksVmin();
34767
34812
  }
34768
34813
 
34814
+ // Signed data whose zero is a reference point — difference images,
34815
+ // phase, field maps, statistical maps — only reads correctly when zero
34816
+ // sits at the centre of the colormap; window it asymmetrically and the
34817
+ // neutral colour drifts off zero, so sign can no longer be judged by
34818
+ // eye. This lock ties the bounds together to prevent that. It stays
34819
+ // opt-in rather than auto-engaging on any signed data, because plenty
34820
+ // of signed data (CT in Hounsfield units) has no symmetry about zero.
34821
+ function _dmenuEffectiveSymmetricLocked() {
34822
+ return symmetricLocked;
34823
+ }
34824
+
34769
34825
  function _dmenuRows() {
34770
34826
  const qmriPolicyLocksVmin = _dmenuQmriPolicyLocksVmin();
34771
34827
  const zeroFloorLocksVmin = _dmenuZeroFloorLocksVmin();
@@ -34773,6 +34829,8 @@
34773
34829
  return [
34774
34830
  { kind: 'lock', action: 'vmin', label: 'vmin', policyLocked: policyLocksVmin, zeroFloorLocked: zeroFloorLocksVmin, qmriPolicyLocked: qmriPolicyLocksVmin, note: _dmenuEffectiveVminLocked() ? 'locked' : '' },
34775
34831
  { kind: 'lock', action: 'vmax', label: 'vmax', note: vmaxLocked ? 'locked' : '' },
34832
+ { kind: 'lock', action: 'symmetric', label: '±', note: _dmenuEffectiveSymmetricLocked() ? 'locked' : '',
34833
+ lockTitle: _dmenuEffectiveSymmetricLocked() ? 'symmetric: zero centred' : 'symmetric: unlocked' },
34776
34834
  ];
34777
34835
  }
34778
34836
 
@@ -34799,11 +34857,13 @@
34799
34857
  btn.setAttribute('aria-checked', locked ? 'true' : 'false');
34800
34858
  if (idx === _dmenuSelectedIdx) btn.classList.add('is-selected');
34801
34859
  if (locked) btn.classList.add('is-locked');
34802
- const lockLabel = row.qmriPolicyLocked
34803
- ? `${row.label}: locked at 0 by qMRI role`
34804
- : row.zeroFloorLocked
34805
- ? `${row.label}: locked at 0`
34806
- : (locked ? `${row.label}: locked` : `${row.label}: unlocked`);
34860
+ const lockLabel = row.lockTitle
34861
+ ? row.lockTitle
34862
+ : row.qmriPolicyLocked
34863
+ ? `${row.label}: locked at 0 by qMRI role`
34864
+ : row.zeroFloorLocked
34865
+ ? `${row.label}: locked at 0`
34866
+ : (locked ? `${row.label}: locked` : `${row.label}: unlocked`);
34807
34867
  btn.title = lockLabel;
34808
34868
  btn.setAttribute('aria-label', lockLabel);
34809
34869
  btn.innerHTML = locked
@@ -34825,7 +34885,11 @@
34825
34885
  });
34826
34886
  return btn;
34827
34887
  };
34828
- return [makeLock(rows[0], 0, _dmenuEffectiveVminLocked()), makeLock(rows[1], 1, vmaxLocked)];
34888
+ return [
34889
+ makeLock(rows[0], 0, _dmenuEffectiveVminLocked()),
34890
+ makeLock(rows[1], 1, vmaxLocked),
34891
+ makeLock(rows[2], 2, _dmenuEffectiveSymmetricLocked()),
34892
+ ];
34829
34893
  }
34830
34894
 
34831
34895
  function _dmenuSetBoundManual(action, value) {
@@ -34865,6 +34929,14 @@
34865
34929
  if (compareMvActive) compareMvViews.forEach(v => { v.lockedVmax = manualVmax; });
34866
34930
  if (compareQmriActive) compareQmriViews.forEach(v => { v.lockedVmax = manualVmax; });
34867
34931
  }
34932
+ // Under a symmetric lock the bounds move together, so mirror the
34933
+ // write through the same per-mode fan-out above rather than
34934
+ // duplicating it. The guard stops the mirror from recursing.
34935
+ if (!_dmenuSymmetricMirroring && _dmenuEffectiveSymmetricLocked()) {
34936
+ _dmenuSymmetricMirroring = true;
34937
+ try { _dmenuSetBoundManual(action === 'vmin' ? 'vmax' : 'vmin', -value); }
34938
+ finally { _dmenuSymmetricMirroring = false; }
34939
+ }
34868
34940
  }
34869
34941
 
34870
34942
  function _dmenuClearPresetForManualRange(target) {
@@ -35351,6 +35423,26 @@
35351
35423
  if (_dmenuApplyBoundPreset(row.action, row.index)) _dmenuCloseAfterSelection();
35352
35424
  return;
35353
35425
  }
35426
+ if (row.action === 'symmetric') {
35427
+ symmetricLocked = !symmetricLocked;
35428
+ if (symmetricLocked) {
35429
+ // Snap the current window symmetric straight away so the
35430
+ // toggle has a visible effect rather than only shaping
35431
+ // later adjustments. The widest bound sets the half-width
35432
+ // so nothing currently visible gets clipped away.
35433
+ const half = Math.max(
35434
+ Math.abs(Number(manualVmin ?? currentVmin)),
35435
+ Math.abs(Number(manualVmax ?? currentVmax)),
35436
+ );
35437
+ if (Number.isFinite(half) && half > 0) _dmenuSetBoundManual('vmax', half);
35438
+ }
35439
+ _dmenuRefreshAfterRangeChange();
35440
+ _dmenuPickerRender();
35441
+ _restartHistAutoDismiss();
35442
+ saveState();
35443
+ if (closeAfterSelection) _dmenuCloseAfterSelection();
35444
+ return;
35445
+ }
35354
35446
  if (row.action === 'vmin') {
35355
35447
  if (row.zeroFloorLocked) {
35356
35448
  _dmenuZeroFloorVminUnlocked = true;
@@ -275,6 +275,12 @@ def _open_browser(
275
275
  "ArrayView could not safely install its VS Code opener in "
276
276
  "this exact remote window; no viewer request was sent."
277
277
  )
278
+ elif _extension_state._VSCODE_EXT_NO_LIVE_WINDOW:
279
+ detail = (
280
+ "This terminal was opened before its VS Code window reloaded, "
281
+ "so it still points at a window that no longer exists. Open a "
282
+ "new terminal in the target window and retry."
283
+ )
278
284
  elif _extension_state._VSCODE_EXT_RELOAD_REQUIRED:
279
285
  detail = (
280
286
  "ArrayView's VS Code opener was installed; reload this exact "
@@ -54,7 +54,8 @@ _VSCODE_EXT_INSTALLED = False # cached so we only check once per process
54
54
  _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
55
55
  _VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
56
56
  _VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
57
- _VSCODE_EXT_VERSION = "0.14.88" # current bundled extension version
57
+ _VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
58
+ _VSCODE_EXT_VERSION = "0.14.90" # current bundled extension version
58
59
  _VSCODE_CONFIGURED_PORTS: set[int] = set()
59
60
 
60
61
  def _bundled_vscode_vsix_version(vsix_path: str) -> str | None:
@@ -357,7 +358,12 @@ def _run_extension_installer(command: list[str], env: dict[str, str]) -> subproc
357
358
  stderr=subprocess.PIPE,
358
359
  text=True,
359
360
  env=env,
361
+ # Same intent on both platforms: the installer gets its own group, so
362
+ # neither its shutdown nor a console Ctrl-C crosses between us and it.
360
363
  start_new_session=os.name != "nt",
364
+ creationflags=(
365
+ subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
366
+ ),
361
367
  )
362
368
  try:
363
369
  stdout, stderr = process.communicate(timeout=30)
@@ -471,9 +477,11 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
471
477
  """
472
478
  global _VSCODE_EXT_INSTALLED, _VSCODE_EXT_FRESH_INSTALL
473
479
  global _VSCODE_EXT_RELOAD_REQUIRED, _VSCODE_EXT_INSTALL_FAILED
480
+ global _VSCODE_EXT_NO_LIVE_WINDOW
474
481
  _VSCODE_EXT_FRESH_INSTALL = False
475
482
  _VSCODE_EXT_RELOAD_REQUIRED = False
476
483
  _VSCODE_EXT_INSTALL_FAILED = False
484
+ _VSCODE_EXT_NO_LIVE_WINDOW = False
477
485
 
478
486
  vsix_path = str(_pkg_files("arrayview").joinpath("arrayview-opener.vsix"))
479
487
  if not os.path.isfile(vsix_path):
@@ -496,6 +504,31 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
496
504
  and active_registration.get("extensionVersion") == active_version
497
505
  else None
498
506
  )
507
+ def _reject_unmatched_host(installed_version: str) -> bool:
508
+ """Record why the live host was rejected, so callers can advise correctly.
509
+
510
+ A version disagreement is fixed by reloading; an unidentifiable window is
511
+ not. Conflating them tells the user to reload when the reload is what
512
+ stranded their terminal in the first place.
513
+ """
514
+ global _VSCODE_EXT_RELOAD_REQUIRED, _VSCODE_EXT_NO_LIVE_WINDOW
515
+ if active_version is None:
516
+ _VSCODE_EXT_NO_LIVE_WINDOW = True
517
+ _vprint(
518
+ f"[ArrayView] opener v{installed_version} is installed, but no live "
519
+ "VS Code window claims this terminal; open a new terminal in the "
520
+ "target window",
521
+ flush=True,
522
+ )
523
+ return False
524
+ _VSCODE_EXT_RELOAD_REQUIRED = True
525
+ _vprint(
526
+ f"[ArrayView] opener v{installed_version} is installed, but this VS Code "
527
+ "window is still running an older extension host; reload this window",
528
+ flush=True,
529
+ )
530
+ return False
531
+
499
532
  if _extension_on_disk(ext_version, vsix_path, remote=is_remote):
500
533
  active_matches = (
501
534
  active_version == ext_version
@@ -503,13 +536,7 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
503
536
  else active_version in (None, ext_version)
504
537
  )
505
538
  if not active_matches:
506
- _VSCODE_EXT_RELOAD_REQUIRED = True
507
- _vprint(
508
- f"[ArrayView] opener v{ext_version} is installed, but this VS Code "
509
- "window is still running an older extension host; reload this window",
510
- flush=True,
511
- )
512
- return False
539
+ return _reject_unmatched_host(ext_version)
513
540
  if not is_remote:
514
541
  _remove_old_extension_versions(ext_version, remote=False)
515
542
  _VSCODE_EXT_INSTALLED = True
@@ -527,13 +554,7 @@ def _ensure_vscode_extension(*, is_remote: bool | None = None) -> bool:
527
554
  else active_version in (None, newer_version)
528
555
  )
529
556
  if not active_matches:
530
- _VSCODE_EXT_RELOAD_REQUIRED = True
531
- _vprint(
532
- f"[ArrayView] newer opener v{newer_version} is installed, but this "
533
- "VS Code window is still running an older extension host; reload this window",
534
- flush=True,
535
- )
536
- return False
557
+ return _reject_unmatched_host(newer_version)
537
558
  _VSCODE_EXT_INSTALLED = True
538
559
  _vprint(
539
560
  f"[ArrayView] newer extension v{newer_version} is installed — keeping it",
@@ -11,6 +11,29 @@ import uvicorn
11
11
  from arrayview._app import app
12
12
 
13
13
 
14
+ # ---------------------------------------------------------------------------
15
+ # Isolation
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def isolated_runtime_directory(tmp_path_factory, monkeypatch):
20
+ """Give every test its own instance registry.
21
+
22
+ An in-process ``view()`` registers the *running interpreter* as an ArrayView
23
+ instance. Sharing one registry therefore lets a later test that shells out
24
+ to ``arrayview stop`` discover the test runner, verify it as a legitimately
25
+ owned instance, and SIGTERM it — pytest dies mid-file with exit 143 and no
26
+ failing test to point at. Subprocesses inherit the variable, so the whole
27
+ tree stays inside the sandbox.
28
+
29
+ It also keeps a local test run from reaching the developer's real registry
30
+ and stopping viewers they have open.
31
+ """
32
+ monkeypatch.setenv(
33
+ "ARRAYVIEW_RUNTIME_DIR", str(tmp_path_factory.mktemp("runtime"))
34
+ )
35
+
36
+
14
37
  # ---------------------------------------------------------------------------
15
38
  # Server
16
39
  # ---------------------------------------------------------------------------