arrayview 0.12.1__tar.gz → 0.12.2__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 (71) hide show
  1. {arrayview-0.12.1 → arrayview-0.12.2}/.claude/skills/viewer-ui-checklist/SKILL.md +10 -2
  2. {arrayview-0.12.1 → arrayview-0.12.2}/AGENTS.md +2 -1
  3. {arrayview-0.12.1 → arrayview-0.12.2}/PKG-INFO +1 -1
  4. {arrayview-0.12.1 → arrayview-0.12.2}/pyproject.toml +1 -1
  5. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_stdio_server.py +6 -1
  6. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_viewer.html +279 -97
  7. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_vscode.py +71 -1
  8. arrayview-0.12.2/src/arrayview/arrayview-opener.vsix +0 -0
  9. {arrayview-0.12.1 → arrayview-0.12.2}/uv.lock +1 -1
  10. {arrayview-0.12.1 → arrayview-0.12.2}/vscode-extension/extension.js +28 -0
  11. arrayview-0.12.1/src/arrayview/arrayview-opener.vsix +0 -0
  12. {arrayview-0.12.1 → arrayview-0.12.2}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  13. {arrayview-0.12.1 → arrayview-0.12.2}/.claude/skills/modes-consistency/SKILL.md +0 -0
  14. {arrayview-0.12.1 → arrayview-0.12.2}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  15. {arrayview-0.12.1 → arrayview-0.12.2}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  16. {arrayview-0.12.1 → arrayview-0.12.2}/.github/workflows/docs.yml +0 -0
  17. {arrayview-0.12.1 → arrayview-0.12.2}/.github/workflows/python-publish.yml +0 -0
  18. {arrayview-0.12.1 → arrayview-0.12.2}/.gitignore +0 -0
  19. {arrayview-0.12.1 → arrayview-0.12.2}/.python-version +0 -0
  20. {arrayview-0.12.1 → arrayview-0.12.2}/CONTRIBUTING.md +0 -0
  21. {arrayview-0.12.1 → arrayview-0.12.2}/LICENSE +0 -0
  22. {arrayview-0.12.1 → arrayview-0.12.2}/README.md +0 -0
  23. {arrayview-0.12.1 → arrayview-0.12.2}/docs/comparing.md +0 -0
  24. {arrayview-0.12.1 → arrayview-0.12.2}/docs/configuration.md +0 -0
  25. {arrayview-0.12.1 → arrayview-0.12.2}/docs/display.md +0 -0
  26. {arrayview-0.12.1 → arrayview-0.12.2}/docs/index.md +0 -0
  27. {arrayview-0.12.1 → arrayview-0.12.2}/docs/loading.md +0 -0
  28. {arrayview-0.12.1 → arrayview-0.12.2}/docs/logo.png +0 -0
  29. {arrayview-0.12.1 → arrayview-0.12.2}/docs/measurement.md +0 -0
  30. {arrayview-0.12.1 → arrayview-0.12.2}/docs/remote.md +0 -0
  31. {arrayview-0.12.1 → arrayview-0.12.2}/docs/stylesheets/extra.css +0 -0
  32. {arrayview-0.12.1 → arrayview-0.12.2}/docs/viewing.md +0 -0
  33. {arrayview-0.12.1 → arrayview-0.12.2}/matlab/arrayview.m +0 -0
  34. {arrayview-0.12.1 → arrayview-0.12.2}/mkdocs.yml +0 -0
  35. {arrayview-0.12.1 → arrayview-0.12.2}/plans/webview/LOG.md +0 -0
  36. {arrayview-0.12.1 → arrayview-0.12.2}/scripts/demo.py +0 -0
  37. {arrayview-0.12.1 → arrayview-0.12.2}/scripts/release.sh +0 -0
  38. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/ARCHITECTURE.md +0 -0
  39. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/__init__.py +0 -0
  40. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/__main__.py +0 -0
  41. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_app.py +0 -0
  42. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_config.py +0 -0
  43. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_icon.png +0 -0
  44. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_io.py +0 -0
  45. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_launcher.py +0 -0
  46. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_platform.py +0 -0
  47. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_render.py +0 -0
  48. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_segmentation.py +0 -0
  49. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_server.py +0 -0
  50. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_session.py +0 -0
  51. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_shell.html +0 -0
  52. {arrayview-0.12.1 → arrayview-0.12.2}/src/arrayview/_torch.py +0 -0
  53. {arrayview-0.12.1 → arrayview-0.12.2}/tests/conftest.py +0 -0
  54. {arrayview-0.12.1 → arrayview-0.12.2}/tests/make_vectorfield_test_arrays.py +0 -0
  55. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_api.py +0 -0
  56. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_browser.py +0 -0
  57. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_cli.py +0 -0
  58. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_command_reachability.py +0 -0
  59. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_config.py +0 -0
  60. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_interactions.py +0 -0
  61. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_large_arrays.py +0 -0
  62. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_mode_consistency.py +0 -0
  63. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_mode_matrix.py +0 -0
  64. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_mode_roundtrip.py +0 -0
  65. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_nifti_meta.py +0 -0
  66. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_rgb_pixel_art.py +0 -0
  67. {arrayview-0.12.1 → arrayview-0.12.2}/tests/test_torch.py +0 -0
  68. {arrayview-0.12.1 → arrayview-0.12.2}/tests/ui_audit.py +0 -0
  69. {arrayview-0.12.1 → arrayview-0.12.2}/tests/visual_smoke.py +0 -0
  70. {arrayview-0.12.1 → arrayview-0.12.2}/vscode-extension/LICENSE +0 -0
  71. {arrayview-0.12.1 → arrayview-0.12.2}/vscode-extension/package.json +0 -0
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: viewer-ui-checklist
3
- description: Use when adding keyboard shortcuts, changing layout, or making any UI change to arrayview. Ensures visual_smoke.py stays in sync.
3
+ description: Use when adding keyboard shortcuts, changing layout, or making any UI change to arrayview. Ensures visual_smoke.py, help overlay, and README stay in sync.
4
4
  ---
5
5
 
6
6
  # ArrayView UI Checklist
7
7
 
8
8
  ## Rule
9
9
 
10
- Every UI change to arrayview MUST be reflected in `tests/visual_smoke.py` before the task is complete.
10
+ Every UI change to arrayview MUST be reflected in `tests/visual_smoke.py`, the help overlay, and (when user-facing) `README.md` before the task is complete.
11
11
 
12
12
  ## What counts as a UI change
13
13
 
@@ -35,6 +35,14 @@ Every UI change to arrayview MUST be reflected in `tests/visual_smoke.py` before
35
35
 
36
36
  4. **Open and review** the new screenshots in `tests/smoke_output/`
37
37
 
38
+ 5. **Update `GUIDE_TABS`** in `_viewer.html` if you added, removed, or changed a keybinding
39
+ - Add/update the entry in the appropriate tab and section
40
+ - Include a `hint` for non-obvious shortcuts
41
+ - Use the `docs-style` skill for formatting rules
42
+
43
+ 6. **Update `README.md`** if the change is user-facing (new CLI flag, new API, new mode)
44
+ - Use the `docs-style` skill for formatting rules
45
+
38
46
  ## Red flags — STOP
39
47
 
40
48
  - "The shortcut is too simple to need a smoke test" → ALL shortcuts need entries
@@ -15,7 +15,8 @@ Load the relevant skill before touching the corresponding area.
15
15
  | `frontend-designer` | Styling/layout changes to `_viewer.html` |
16
16
  | `vscode-simplebrowser` | Extension, signal-file IPC, `_VSCODE_EXT_VERSION` |
17
17
  | `invocation-consistency` | Server startup, display-opening, env detection |
18
- | `docs-style` | README, help overlay, docstrings |
18
+ | `viewer-ui-checklist` | Any UI change (smoke tests, help overlay, README sync) |
19
+ | `docs-style` | README, help overlay, docstrings (formatting rules) |
19
20
 
20
21
  ## Non-Negotiables
21
22
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.12.1
3
+ Version: 0.12.2
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.12.1"
7
+ version = "0.12.2"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -637,7 +637,7 @@ def _handle_get_viewer_html(msg: dict) -> None:
637
637
  """Return the rendered viewer HTML with template substitutions."""
638
638
  from importlib.resources import files as _pkg_files
639
639
 
640
- from arrayview._config import get_viewer_colormaps
640
+ from arrayview._config import get_viewer_colormaps, get_viewer_theme
641
641
  from arrayview._render import COLORMAP_GRADIENT_STOPS, COMPLEX_MODES, REAL_MODES
642
642
  from arrayview._session import COLORMAPS
643
643
 
@@ -650,12 +650,17 @@ def _handle_get_viewer_html(msg: dict) -> None:
650
650
 
651
651
  query_val = json.dumps(f"?sid={sid}&transport=postMessage") if sid else "null"
652
652
 
653
+ _theme_names = ["dark", "light", "solarized", "nord"]
654
+ _cfg_theme = get_viewer_theme()
655
+ _default_theme_idx = _theme_names.index(_cfg_theme) if _cfg_theme in _theme_names else 0
656
+
653
657
  html = (
654
658
  template.replace("__COLORMAPS__", str(_active_colormaps))
655
659
  .replace("__COLORMAP_GRADIENT_STOPS__", json.dumps(COLORMAP_GRADIENT_STOPS))
656
660
  .replace("__COMPLEX_MODES__", str(COMPLEX_MODES))
657
661
  .replace("__REAL_MODES__", str(REAL_MODES))
658
662
  .replace("__ARRAYVIEW_QUERY__", query_val)
663
+ .replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
659
664
  )
660
665
 
661
666
  _write_json({"html": html})
@@ -535,14 +535,7 @@
535
535
  width: min(820px, 92vw); height: min(520px, 86vh);
536
536
  overflow: hidden;
537
537
  }
538
- .help-topbar {
539
- display: flex; justify-content: space-between; align-items: center;
540
- margin-bottom: 14px; flex-shrink: 0;
541
- }
542
- .help-mode-hint {
543
- font-size: 11px; color: var(--muted); letter-spacing: 0.04em;
544
- }
545
- .help-mode-hint kbd, .help-footer kbd {
538
+ .help-footer kbd {
546
539
  display: inline-block; border: 1px solid var(--border);
547
540
  border-radius: 4px; padding: 0px 5px;
548
541
  font-family: 'SF Mono', ui-monospace, monospace;
@@ -579,13 +572,23 @@
579
572
  margin-bottom: 8px; padding-bottom: 4px;
580
573
  border-bottom: 1px solid var(--border-subtle);
581
574
  }
575
+ .help-section-title:not(:first-child) { margin-top: 16px; }
576
+ .help-subsection-title {
577
+ font-size: 12px; font-weight: 600; color: var(--text);
578
+ margin-top: 18px; margin-bottom: 4px;
579
+ }
580
+ .help-subsection-title:first-child { margin-top: 0; }
581
+ .help-subsection-desc {
582
+ color: var(--muted); font-size: 11.5px;
583
+ line-height: 1.6; margin-bottom: 10px;
584
+ }
582
585
  .help-section { margin-bottom: 18px; }
583
586
  .help-columns { display: flex; gap: 28px; }
584
587
  .help-column { flex: 1; min-width: 0; }
585
588
  .help-entry { margin-bottom: 8px; }
586
589
  .help-entry-row { display: flex; align-items: baseline; }
587
590
  .help-entry-key {
588
- flex-shrink: 0; width: 88px;
591
+ flex-shrink: 0; width: 100px;
589
592
  color: var(--help-key); font-weight: 600;
590
593
  font-family: 'SF Mono', ui-monospace, 'Cascadia Code', monospace;
591
594
  font-size: 12px;
@@ -593,15 +596,14 @@
593
596
  .help-entry-action { color: var(--text); font-size: 12.5px; }
594
597
  .help-entry-hint {
595
598
  color: var(--muted); font-size: 11px;
596
- margin-left: 88px; margin-top: 1px;
599
+ margin-left: 100px; margin-top: 1px;
597
600
  }
598
601
  .help-footer {
599
602
  flex-shrink: 0; border-top: 1px solid var(--border-subtle);
600
603
  margin-top: 14px; padding-top: 10px;
601
- display: flex; justify-content: space-between; align-items: center;
604
+ display: flex; justify-content: flex-end; align-items: center;
602
605
  font-size: 11px; color: var(--muted); letter-spacing: 0.04em;
603
606
  }
604
- .help-footer-nav kbd { margin-right: 2px; }
605
607
  .help-footer-link {
606
608
  color: var(--muted); text-decoration: none;
607
609
  font-size: 11px; letter-spacing: 0.04em;
@@ -1501,14 +1503,10 @@
1501
1503
  </div>
1502
1504
  <div id="help-overlay">
1503
1505
  <div id="help-box">
1504
- <div class="help-topbar">
1505
- <span class="help-mode-hint">press <kbd>?</kbd> to close</span>
1506
- </div>
1507
1506
  <div id="help-tabs"></div>
1508
1507
  <div id="help-content"></div>
1509
1508
  <div class="help-footer">
1510
- <span class="help-footer-nav"><kbd>h</kbd><kbd>l</kbd> / <kbd>← →</kbd> switch tabs</span>
1511
- <a class="help-footer-link" href="https://github.com/brainhack-school2024/arrayview#keybindings" target="_blank" rel="noopener">Full reference →</a>
1509
+ <a class="help-footer-link" href="https://oscarvanderheide.github.io/arrayview/" target="_blank" rel="noopener">Full reference →</a>
1512
1510
  </div>
1513
1511
  </div>
1514
1512
  </div>
@@ -1749,7 +1747,8 @@
1749
1747
  let lebesgueMode = false; // Lebesgue integral: highlight pixels matching hovered bin
1750
1748
  let _lebesgueSlice = null; // Float32Array of raw 2-D slice values (row-major)
1751
1749
  let _lebesgueVersion = null; // version key for cached _lebesgueSlice
1752
- let _lebesgueFetching = false; // prevents concurrent fetches
1750
+ let _lebesgueFetchCtrl = null; // AbortController for in-flight Lebesgue fetch
1751
+ let _hoverPrefetchTimer = null; // debounce for hover prefetch during rapid scrolling
1753
1752
  let _lastLebesgueBin = -1; // tracks which histogram bin is currently highlighted
1754
1753
  let _lebesgueSliceW = 0, _lebesgueSliceH = 0; // dimensions of cached raw slice
1755
1754
  let rgbMode = false;
@@ -5035,6 +5034,16 @@
5035
5034
  }
5036
5035
  }
5037
5036
 
5037
+ /** Debounced prefetch — safe to call on every wheel / key event. */
5038
+ function _debouncedHoverPrefetch() {
5039
+ if (!_pixelInfoVisible && !lebesgueMode) return;
5040
+ clearTimeout(_hoverPrefetchTimer);
5041
+ _hoverPrefetchTimer = setTimeout(() => {
5042
+ _fetchLebesgueSlice();
5043
+ if (_pixelInfoVisible) _prefetchHoverSlices();
5044
+ }, 80);
5045
+ }
5046
+
5038
5047
  async function _fetchLebesgueSliceForSid(fetchSid, fetchDimX, fetchDimY) {
5039
5048
  const params = new URLSearchParams({
5040
5049
  dim_x: fetchDimX, dim_y: fetchDimY,
@@ -5058,32 +5067,37 @@
5058
5067
  }
5059
5068
 
5060
5069
  async function _fetchLebesgueSlice() {
5061
- if (!sid || !indices.length || _lebesgueFetching) return;
5062
- _lebesgueFetching = true;
5063
- if (compareActive) {
5064
- // Fetch for all compare sessions in parallel
5065
- const sids = getCompareRenderSids();
5066
- await Promise.all(sids.map(s => _fetchLebesgueSliceForSid(s, dim_x, dim_y)));
5067
- // Also cache in legacy variables for single-session fallback
5068
- const first = _lebesguePerSession[sids[0]];
5069
- if (first) {
5070
- _lebesgueSlice = first.slice;
5071
- _lebesgueSliceW = first.w;
5072
- _lebesgueSliceH = first.h;
5073
- _lebesgueVersion = first.version;
5074
- }
5075
- } else {
5076
- const entry = await _fetchLebesgueSliceForSid(sid, dim_x, dim_y);
5077
- if (entry) {
5078
- _lebesgueSlice = entry.slice;
5079
- _lebesgueSliceW = entry.w;
5080
- _lebesgueSliceH = entry.h;
5081
- _lebesgueVersion = entry.version;
5070
+ if (!sid || !indices.length) return;
5071
+ // Abort any in-flight fetch so rapid slice changes always fetch the latest
5072
+ if (_lebesgueFetchCtrl) _lebesgueFetchCtrl.abort();
5073
+ const ctrl = new AbortController();
5074
+ _lebesgueFetchCtrl = ctrl;
5075
+ try {
5076
+ if (compareActive) {
5077
+ const sids = getCompareRenderSids();
5078
+ await Promise.all(sids.map(s => _fetchLebesgueSliceForSid(s, dim_x, dim_y)));
5079
+ if (ctrl.signal.aborted) return;
5080
+ const first = _lebesguePerSession[sids[0]];
5081
+ if (first) {
5082
+ _lebesgueSlice = first.slice;
5083
+ _lebesgueSliceW = first.w;
5084
+ _lebesgueSliceH = first.h;
5085
+ _lebesgueVersion = first.version;
5086
+ }
5082
5087
  } else {
5083
- _lebesgueSlice = null;
5088
+ const entry = await _fetchLebesgueSliceForSid(sid, dim_x, dim_y);
5089
+ if (ctrl.signal.aborted) return;
5090
+ if (entry) {
5091
+ _lebesgueSlice = entry.slice;
5092
+ _lebesgueSliceW = entry.w;
5093
+ _lebesgueSliceH = entry.h;
5094
+ _lebesgueVersion = entry.version;
5095
+ } else {
5096
+ _lebesgueSlice = null;
5097
+ }
5084
5098
  }
5085
- }
5086
- _lebesgueFetching = false;
5099
+ } catch (_) { /* aborted or network error */ }
5100
+ if (_lebesgueFetchCtrl === ctrl) _lebesgueFetchCtrl = null;
5087
5101
  }
5088
5102
 
5089
5103
  function _clearLebesgueOverlay() {
@@ -7142,14 +7156,18 @@
7142
7156
  renderInfo();
7143
7157
  // Invalidate cached histogram data so next colorbar hover re-fetches
7144
7158
  _histDataVersion = null;
7145
- // Invalidate and clear Lebesgue overlay (slice changed)
7146
- _lebesgueVersion = null;
7147
- Object.keys(_lebesguePerSession).forEach(k => delete _lebesguePerSession[k]);
7148
- Object.keys(_hoverSliceCache).forEach(k => delete _hoverSliceCache[k]);
7159
+ // Lebesgue / hover caches are version-keyed, so stale entries are
7160
+ // never served for the wrong slice. Don't eagerly clear — the old
7161
+ // data remains harmless while the async re-fetch is in flight, and
7162
+ // clearing creates a window where hover falls to the slow /pixel path.
7163
+ // Cap cache size to prevent unbounded memory growth.
7164
+ const _hscKeys = Object.keys(_hoverSliceCache);
7165
+ if (_hscKeys.length > 64) _hscKeys.slice(0, _hscKeys.length - 32).forEach(k => delete _hoverSliceCache[k]);
7166
+ const _lpsKeys = Object.keys(_lebesguePerSession);
7167
+ if (_lpsKeys.length > 32) _lpsKeys.slice(0, _lpsKeys.length - 16).forEach(k => delete _lebesguePerSession[k]);
7149
7168
  if (lebesgueMode || _pixelInfoVisible) {
7150
7169
  if (lebesgueMode) _clearLebesgueOverlay();
7151
- _fetchLebesgueSlice();
7152
- if (_pixelInfoVisible) _prefetchHoverSlices();
7170
+ _debouncedHoverPrefetch();
7153
7171
  }
7154
7172
  if (compareQmriActive) {
7155
7173
  compareQmriViews.forEach(v => compareQmriRender(v));
@@ -9232,19 +9250,53 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
9232
9250
  },
9233
9251
  'overlay.cycleVisibility': {
9234
9252
  title: 'Cycle overlay visibility',
9235
- when: ['hasOverlay'],
9253
+ when: [],
9236
9254
  run: (ctx, e) => {
9255
+ const hasOv = !!overlay_sid;
9256
+ const hasVf = hasVectorfield;
9257
+ if (!hasOv && !hasVf) return;
9258
+
9259
+ // Helper: show or hide vector field arrows
9260
+ const setVfield = (show) => {
9261
+ if (!hasVf) return;
9262
+ vfieldVisible = show;
9263
+ if (!show) {
9264
+ if (_vfieldAbort) { _vfieldAbort.abort(); _vfieldAbort = null; }
9265
+ vfieldCanvas.style.display = 'none';
9266
+ if (multiViewActive) {
9267
+ _mvVfieldAborts.forEach(ac => ac.abort());
9268
+ _mvVfieldAborts.clear();
9269
+ _mvVfieldCache.clear();
9270
+ document.querySelectorAll('.mv-vfield-overlay').forEach(el => el.style.display = 'none');
9271
+ }
9272
+ } else {
9273
+ if (multiViewActive) mvDrawAllVectorOverlays();
9274
+ else drawVectorOverlay();
9275
+ }
9276
+ };
9277
+
9278
+ // Vector field only (no masks): simple toggle
9279
+ if (!hasOv) {
9280
+ setVfield(!vfieldVisible);
9281
+ showStatus(`arrows: ${vfieldVisible ? 'on' : 'off'}`);
9282
+ saveState();
9283
+ return;
9284
+ }
9285
+
9237
9286
  // Cycle overlay visibility: all → none → mask0 → mask1 → ... → all
9238
9287
  const sids = overlay_sid.split(',');
9239
9288
  const nMasks = sids.length;
9240
9289
  if (_overlayVisibility === 'all') {
9241
9290
  _overlayVisibility = 'none';
9291
+ setVfield(false);
9242
9292
  } else if (_overlayVisibility === 'none') {
9243
9293
  _overlayVisibility = nMasks > 1 ? 0 : 'all';
9294
+ if (_overlayVisibility === 'all') setVfield(true);
9244
9295
  } else {
9245
9296
  // Currently showing individual mask
9246
9297
  const next = _overlayVisibility + 1;
9247
9298
  _overlayVisibility = next >= nMasks ? 'all' : next;
9299
+ if (_overlayVisibility === 'all') setVfield(true);
9248
9300
  }
9249
9301
  // Status message
9250
9302
  if (_overlayVisibility === 'all') {
@@ -9258,6 +9310,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
9258
9310
  showStatus(`overlay ${_overlayVisibility + 1}/${nMasks}${colorTag}`);
9259
9311
  }
9260
9312
  updateView();
9313
+ saveState();
9261
9314
  },
9262
9315
  },
9263
9316
  'overlay.openPicker': {
@@ -9874,7 +9927,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
9874
9927
  when: [],
9875
9928
  run: (ctx, e) => {
9876
9929
  if (!hasVectorfield) {
9877
- showStatus('U: no vector field loaded');
9930
+ showStatus('no vector field loaded');
9878
9931
  return;
9879
9932
  } // kept inline: emits toast
9880
9933
  vfieldVisible = !vfieldVisible;
@@ -10038,6 +10091,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10038
10091
  v.rendering = false; v.pending = false; v.seq++;
10039
10092
  });
10040
10093
  mvViews.forEach(v => { mvDrawFrame(v); mvRender(v); });
10094
+ _debouncedHoverPrefetch();
10041
10095
  showStatus('origin reset');
10042
10096
  },
10043
10097
  },
@@ -10185,6 +10239,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10185
10239
  indices[activeDim] = Math.min(shape[activeDim] - 1, indices[activeDim] + 1);
10186
10240
  renderInfo();
10187
10241
  mvViews.forEach(v => { mvDrawFrame(v); mvRender(v); });
10242
+ _debouncedHoverPrefetch();
10188
10243
  saveState();
10189
10244
  } else if (activeDim === dim_x) {
10190
10245
  flip_x = !flip_x;
@@ -10215,6 +10270,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10215
10270
  indices[activeDim] = Math.max(0, indices[activeDim] - 1);
10216
10271
  renderInfo();
10217
10272
  mvViews.forEach(v => { mvDrawFrame(v); mvRender(v); });
10273
+ _debouncedHoverPrefetch();
10218
10274
  saveState();
10219
10275
  } else if (activeDim === dim_x) {
10220
10276
  flip_x = !flip_x;
@@ -10337,7 +10393,6 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10337
10393
  // Order matters: seg.exit (when segMode) takes precedence over slice.cancelJump
10338
10394
  { key: 'Escape', command: 'seg.exit' },
10339
10395
  { key: 'Escape', command: 'slice.cancelJump' },
10340
- { key: 'U', command: 'vfield.toggleArrows' },
10341
10396
  { key: 'u', command: 'ruler.toggle' },
10342
10397
  ];
10343
10398
 
@@ -10362,97 +10417,189 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10362
10417
  const GUIDE_TABS = [
10363
10418
  {
10364
10419
  id: 'basics', label: 'Basics',
10365
- desc: 'The essentials for navigating and adjusting any array.',
10420
+ desc: 'The essentials for exploring any array. Move through slices, zoom into details, or play through a volume as an animation. These controls work everywhere.',
10421
+ columns: 2,
10422
+ sections: [
10423
+ {
10424
+ title: 'Slices & Dimensions',
10425
+ entries: [
10426
+ { key: '← → ↑ ↓', action: 'Step through slices' },
10427
+ { key: 'x / y', action: 'Change viewing dimension', hint: 'choose which axes map to horizontal and vertical' },
10428
+ { key: 'z', action: 'Mosaic — tile all slices in a grid' },
10429
+ { key: '1–9 Enter', action: 'Jump to a specific slice number' },
10430
+ ]
10431
+ },
10432
+ {
10433
+ title: 'Playback',
10434
+ entries: [
10435
+ { key: 'Space', action: 'Play / pause animation' },
10436
+ { key: '< >', action: 'Slower / faster playback' },
10437
+ ]
10438
+ },
10439
+ {
10440
+ title: 'Info & Search',
10441
+ entries: [
10442
+ { key: 'i', action: 'Toggle hover pixel info', hint: 'shows coordinates and value under cursor' },
10443
+ { key: 'I', action: 'Info panel', hint: 'array shape, dtype, memory layout' },
10444
+ { key: '/', action: 'Command palette', hint: 'search all available commands' },
10445
+ ]
10446
+ },
10447
+ {
10448
+ title: 'Zoom & Pan', columnBreak: true,
10449
+ entries: [
10450
+ { key: 'Pinch / ⌘Scroll', action: 'Zoom in / out', hint: 'Ctrl+Scroll on Windows / Linux' },
10451
+ { key: '0', action: 'Reset zoom to fit' },
10452
+ { key: 'Drag', action: 'Pan the image (when zoomed in)' },
10453
+ { key: 'F', action: 'Zen mode — hide UI chrome', hint: 'strips away panels and controls to focus on the image' },
10454
+ ]
10455
+ },
10456
+ {
10457
+ title: 'Export',
10458
+ entries: [
10459
+ { key: 's', action: 'Save screenshot' },
10460
+ { key: 'g', action: 'Save GIF animation' },
10461
+ { key: 'e', action: 'Copy shareable URL' },
10462
+ ]
10463
+ }
10464
+ ]
10465
+ },
10466
+ {
10467
+ id: 'display', label: 'Display',
10468
+ desc: 'Control how your data looks. Adjust colors and the display range to bring out features that aren\u2019t visible with default settings.',
10366
10469
  columns: 2,
10367
10470
  sections: [
10368
10471
  {
10369
- title: 'Navigation',
10472
+ title: 'Color & Range',
10473
+ entries: [
10474
+ { key: 'c', action: 'Cycle colormap', hint: 'grayscale, viridis, turbo, and more' },
10475
+ { key: 'd', action: 'Cycle display range presets', hint: 'clips outliers — press repeatedly to tighten' },
10476
+ { key: 'L', action: 'Toggle log scale', hint: 'when values span several orders of magnitude' },
10477
+ { key: 'T', action: 'Cycle theme', hint: 'dark, light, solarized, nord' },
10478
+ { key: 'b', action: 'Toggle canvas borders' },
10479
+ ]
10480
+ },
10481
+ {
10482
+ title: 'Colorbar',
10370
10483
  entries: [
10371
- { key: '← → ↑ ↓', action: 'navigate slices', hint: 'step through the volume one slice at a time' },
10372
- { key: 'x / y', action: 'change viewing dimension', hint: 'choose which axes map to screen x and y' },
10373
- { key: 'z', action: 'toggle mosaic', hint: 'tile all slices of the current axis in a grid' },
10374
- { key: 'scroll', action: 'zoom in / out', hint: 'pinch-to-zoom on trackpad' },
10375
- { key: '0', action: 'reset zoom to fit' },
10376
- { key: 'space', action: 'play / pause', hint: '< > adjust playback speed' },
10484
+ { key: 'Scroll', action: 'Narrow or widen the display range' },
10485
+ { key: 'Drag handles', action: 'Adjust min / max bounds' },
10486
+ { key: 'Drag bar', action: 'Shift the visible range up or down' },
10487
+ { key: 'Dbl-click', action: 'Expand histogram' },
10488
+ { key: 'Dbl-click label', action: 'Type an exact min or max value' },
10377
10489
  ]
10378
10490
  },
10379
10491
  {
10380
- title: 'Display',
10492
+ title: 'Transforms', columnBreak: true,
10381
10493
  entries: [
10382
- { key: 'i', action: 'toggle hover pixel info', hint: 'show coordinates and value under cursor' },
10383
- { key: 'c', action: 'cycle colormap', hint: 'grayscale, viridis, turbo, etc.' },
10384
- { key: 'd', action: 'open histogram / cycle quantiles', hint: 'press repeatedly to tighten the intensity range' },
10385
- { key: 'I', action: 'info panel', hint: 'array shape, dtype, memory layout' },
10386
- { key: '/', action: 'command palette', hint: 'search all commands' },
10494
+ { key: 'f', action: 'FFT view frequency domain', hint: 'press again to return to spatial domain' },
10495
+ { key: 'p', action: 'Cycle projections', hint: 'MAX MIN → MEAN → STD → off' },
10496
+ { key: 'm', action: 'Complex mode cycle', hint: 'magnitude phase real imaginary' },
10387
10497
  ]
10388
10498
  }
10389
10499
  ]
10390
10500
  },
10391
10501
  {
10392
10502
  id: 'ortho', label: 'Ortho View',
10393
- desc: 'Three synchronized panels showing axial, sagittal, and coronal views of a 3D volume. Crosshairs link the views so clicking in one updates the others.',
10503
+ desc: 'Three synchronized panels showing your volume from three directions at once. A crosshair links the views clicking in one panel updates the other two. Great for exploring 3D data like MRI scans, where you need to see axial, sagittal, and coronal slices together.',
10394
10504
  sections: [
10395
10505
  {
10506
+ title: 'Controls',
10396
10507
  entries: [
10397
- { key: 'V', action: 'Toggle ortho view', hint: 'Enter or exit the three-panel layout' },
10398
- { key: 'Click', action: 'Set crosshair position', hint: 'Click any panel to jump all views to that point' },
10399
- { key: ' → ↑ ↓', action: 'Move crosshair', hint: 'Fine-tune position with arrow keys' },
10400
- { key: 'Scroll', action: 'Zoom focused panel', hint: 'Zoom applies to the panel under the cursor' },
10401
- { key: 'X Y Z', action: 'Switch axis', hint: 'View the volume from a different orientation' },
10508
+ { key: 'v', action: 'Enter or exit ortho view' },
10509
+ { key: 'Click / Drag', action: 'Set crosshair position', hint: 'click any panel to jump all views to that point, or drag to scrub' },
10510
+ { key: 'Drag lines', action: 'Reposition slice planes', hint: 'each colored line corresponds to a slice in another panel' },
10511
+ { key: 'p', action: 'Maximum intensity projection (MIP)', hint: '3D volume rendering drag to rotate' },
10512
+ { key: 'o', action: 'Reset to origin' },
10402
10513
  ]
10403
10514
  }
10404
10515
  ]
10405
10516
  },
10406
10517
  {
10407
- id: 'roi', label: 'ROI',
10408
- desc: 'Draw a region of interest to see local statistics (mean, std, min, max) and restrict the histogram to that region.',
10518
+ id: 'compare', label: 'Compare',
10519
+ desc: 'View two or more arrays side by side, blend them together, or compute a difference map. Useful for checking registration, comparing model outputs, or inspecting before-and-after changes. Open multiple arrays with the file picker first.',
10409
10520
  sections: [
10410
10521
  {
10522
+ title: 'Layout',
10411
10523
  entries: [
10412
- { key: 'Shift+R', action: 'Toggle ROI mode', hint: 'Enter or exit ROI drawing mode' },
10413
- { key: 'Drag', action: 'Draw ROI rectangle', hint: 'Click and drag on the image to select a region' },
10414
- { key: 'R', action: 'Cycle ROI shape', hint: 'Switch between rectangle, ellipse, and freehand' },
10415
- { key: 'Esc', action: 'Clear ROI', hint: 'Remove the current selection and exit ROI mode' },
10524
+ { key: 'G', action: 'Cycle layout', hint: 'horizontal vertical grid' },
10525
+ { key: 'Drag title', action: 'Reorder panes by dragging their title bar' },
10526
+ ]
10527
+ },
10528
+ {
10529
+ title: 'Operations (requires exactly 2 arrays)',
10530
+ entries: [
10531
+ { key: 'X', action: 'Cycle compare operation', hint: 'off → A−B → |A−B| → relative → overlay → wipe → flicker → checkerboard' },
10532
+ { key: '[ ]', action: 'Adjust operation parameter', hint: 'blend amount, flicker speed, or checkerboard tile size' },
10416
10533
  ]
10417
10534
  }
10418
10535
  ]
10419
10536
  },
10420
10537
  {
10421
- id: 'seg', label: 'Segmentation',
10422
- desc: 'Overlay label maps on your image. Load a segmentation array and toggle visibility, adjust opacity, or pick labels interactively.',
10538
+ id: 'tools', label: 'Tools',
10539
+ desc: 'Draw on the image to measure, segment, or crop. Each tool uses click or drag to define a region, and shows results in real time.',
10423
10540
  sections: [
10424
10541
  {
10542
+ subsection: 'ROI — Region of Interest',
10543
+ subdesc: 'Draw a region to see live statistics (mean, std, min, max). The histogram updates to show only the selected area.',
10544
+ entries: [
10545
+ { key: 'R', action: 'Toggle ROI mode' },
10546
+ { key: 'r', action: 'Cycle shape', hint: 'rectangle → circle → freehand → flood fill' },
10547
+ { key: 'Drag', action: 'Draw ROI region' },
10548
+ { key: 'Right-click', action: 'Delete ROI under cursor' },
10549
+ { key: 'Esc', action: 'Clear all and exit' },
10550
+ ]
10551
+ },
10552
+ {
10553
+ subsection: 'Ruler',
10554
+ subdesc: 'Click two points to measure pixel distance.',
10555
+ entries: [
10556
+ { key: 'u', action: 'Toggle ruler mode' },
10557
+ ]
10558
+ },
10559
+ {
10560
+ subsection: 'Segmentation',
10561
+ subdesc: 'Interactive segmentation with nnInteractive. Click to add or exclude regions from the label.',
10425
10562
  entries: [
10426
- { key: 'S', action: 'Cycle segmentation mode', hint: 'Toggle through available segmentation overlays' },
10427
- { key: 'M', action: 'Toggle mask / adjust alpha', hint: 'Control overlay transparency' },
10428
- { key: 'Click', action: 'Pick label', hint: 'Click on the image to identify a segmentation label' },
10429
- { key: 'Esc', action: 'Exit segmentation mode' },
10563
+ { key: 'S', action: 'Toggle segmentation mode' },
10564
+ { key: 'Click', action: 'Add region (positive prompt)' },
10565
+ { key: 'Shift+Click', action: 'Exclude region (negative prompt)' },
10566
+ { key: 'Esc', action: 'Exit segmentation' },
10430
10567
  ]
10431
10568
  }
10432
10569
  ]
10433
10570
  },
10434
10571
  {
10435
- id: 'compare', label: 'Compare',
10436
- desc: 'View two arrays side by side, overlay them, or compute a difference map. Useful for checking registration, model outputs, or before/after changes.',
10572
+ id: 'overlays', label: 'Overlays',
10573
+ desc: 'Extra layers displayed on top of your array. Load segmentation masks with --overlay or deformation fields with --vectorfield when launching arrayview. Press O to toggle visibility for all overlays at once.',
10437
10574
  sections: [
10438
10575
  {
10576
+ subsection: 'Segmentation Masks',
10577
+ subdesc: 'Binary masks rendered as colored layers on the image. Load one or more with --overlay mask1.nii,mask2.nii. Each mask gets its own color.',
10578
+ entries: [
10579
+ { key: 'O', action: 'Cycle visibility', hint: 'all → none → individual masks (if multiple)' },
10580
+ { key: '[ ]', action: 'Adjust opacity' },
10581
+ ]
10582
+ },
10583
+ {
10584
+ subsection: 'Vector Field',
10585
+ subdesc: 'Deformation or flow fields rendered as arrows. Load with --vectorfield field.nii.',
10439
10586
  entries: [
10440
- { key: 'n', action: 'cycle compare target', hint: 'switch which array you\'re comparing against' },
10441
- { key: 'G', action: 'Cycle compare layout', hint: 'Side-by-side, overlay, checkerboard, or diff' },
10442
- { key: 'Z', action: 'Toggle focus mode', hint: 'Zoom into the comparison view' },
10587
+ { key: 'O', action: 'Toggle visibility' },
10588
+ { key: '[ ]', action: 'Adjust arrow density' },
10589
+ { key: '{ }', action: 'Adjust arrow length' },
10443
10590
  ]
10444
10591
  }
10445
10592
  ]
10446
10593
  },
10447
10594
  {
10448
10595
  id: 'qmri', label: 'qMRI',
10449
- desc: 'Quantitative MRI mode for parameter maps (T1, T2, FA, etc.). Displays voxel values with proper units and provides tissue-specific presets.',
10596
+ desc: 'For quantitative MRI parameter maps T1, T2, FA, and others. Voxel values are shown with proper units and the display adjusts to tissue-specific intensity ranges. Use this when viewing parameter maps to get meaningful readouts instead of raw numbers.',
10450
10597
  sections: [
10451
10598
  {
10599
+ title: 'Controls',
10452
10600
  entries: [
10453
- { key: 'q', action: 'enter / cycle / exit qMRI', hint: 'step through available qMRI parameter modes' },
10454
- { key: 'Z', action: 'Toggle qMRI mosaic', hint: 'See all parameter maps in a grid' },
10455
- { key: 'R', action: 'Cycle range preset', hint: 'Tissue-specific intensity ranges for each parameter' },
10601
+ { key: 'q', action: 'Enter / cycle / exit qMRI mode', hint: 'steps through available parameter types' },
10602
+ { key: 'z', action: 'Toggle qMRI mosaic', hint: 'see all parameter maps side by side in a grid' },
10456
10603
  ]
10457
10604
  }
10458
10605
  ]
@@ -10491,10 +10638,37 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10491
10638
  const wrapper = document.createElement('div');
10492
10639
  if (tab.columns === 2) wrapper.className = 'help-columns';
10493
10640
 
10641
+ let col = null; // current column container
10494
10642
  for (const section of tab.sections) {
10495
- const col = document.createElement('div');
10496
- if (tab.columns === 2) col.className = 'help-column';
10643
+ // Start a new column if 2-column layout and this is the first section or a columnBreak
10644
+ if (tab.columns === 2) {
10645
+ if (!col || section.columnBreak) {
10646
+ col = document.createElement('div');
10647
+ col.className = 'help-column';
10648
+ wrapper.appendChild(col);
10649
+ }
10650
+ } else {
10651
+ if (!col) {
10652
+ col = wrapper; // single-column: append directly to wrapper
10653
+ }
10654
+ }
10655
+
10656
+ // Subsection header (bold title + description paragraph)
10657
+ if (section.subsection) {
10658
+ const subTitle = document.createElement('div');
10659
+ subTitle.className = 'help-subsection-title';
10660
+ subTitle.textContent = section.subsection;
10661
+ col.appendChild(subTitle);
10497
10662
 
10663
+ if (section.subdesc) {
10664
+ const subDesc = document.createElement('div');
10665
+ subDesc.className = 'help-subsection-desc';
10666
+ subDesc.textContent = section.subdesc;
10667
+ col.appendChild(subDesc);
10668
+ }
10669
+ }
10670
+
10671
+ // Section title (uppercase label with divider)
10498
10672
  if (section.title) {
10499
10673
  const titleEl = document.createElement('div');
10500
10674
  titleEl.className = 'help-section-title';
@@ -10530,8 +10704,6 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
10530
10704
 
10531
10705
  col.appendChild(entryEl);
10532
10706
  }
10533
-
10534
- wrapper.appendChild(col);
10535
10707
  }
10536
10708
 
10537
10709
  panel.appendChild(wrapper);
@@ -11577,6 +11749,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
11577
11749
  activeDim = view.sliceDir;
11578
11750
  renderInfo();
11579
11751
  mvViews.forEach(v => { mvDrawFrame(v); mvRender(v); });
11752
+ _debouncedHoverPrefetch();
11580
11753
  saveState();
11581
11754
  }, { passive: false });
11582
11755
  cv.addEventListener('mousedown', (e) => {
@@ -12319,6 +12492,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
12319
12492
  indices[current_slice_dim] = Math.max(0, Math.min(shape[current_slice_dim] - 1, indices[current_slice_dim] + delta));
12320
12493
  renderInfo();
12321
12494
  qmriViews.forEach(v => qvRender(v));
12495
+ _debouncedHoverPrefetch();
12322
12496
  saveState();
12323
12497
  }, { passive: false });
12324
12498
  cv.addEventListener('mousemove', (e) => {
@@ -12538,6 +12712,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
12538
12712
  indices[current_slice_dim] = Math.max(0, Math.min(shape[current_slice_dim] - 1, indices[current_slice_dim] + delta));
12539
12713
  renderInfo();
12540
12714
  compareQmriViews.forEach(v => compareQmriRender(v));
12715
+ _debouncedHoverPrefetch();
12541
12716
  saveState();
12542
12717
  }, { passive: false });
12543
12718
  cv.addEventListener('mousemove', (e) => {
@@ -12891,6 +13066,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
12891
13066
  indices[view.sliceDir] = Math.max(0, Math.min(shape[view.sliceDir] - 1, indices[view.sliceDir] + delta));
12892
13067
  renderInfo();
12893
13068
  compareMvViews.forEach(v => compareMvRender(v));
13069
+ _debouncedHoverPrefetch();
12894
13070
  saveState();
12895
13071
  }, { passive: false });
12896
13072
  cv.addEventListener('mousedown', (e) => {
@@ -12995,6 +13171,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
12995
13171
  fy ? shape[view.dimY] - 1 - Math.floor(py) : Math.floor(py)));
12996
13172
  renderInfo();
12997
13173
  compareMvViews.forEach(v => { compareMvDrawFrame(v); compareMvRender(v); });
13174
+ _debouncedHoverPrefetch();
12998
13175
  }
12999
13176
 
13000
13177
  function exitCompareMv() {
@@ -13641,6 +13818,7 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
13641
13818
  renderInfo();
13642
13819
  mvViews.forEach(v => { mvDrawFrame(v); mvRender(v); });
13643
13820
  }
13821
+ _debouncedHoverPrefetch();
13644
13822
  }
13645
13823
 
13646
13824
  async function mvRenderOblique(v) {
@@ -14333,7 +14511,8 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
14333
14511
  else if (compareMvActive) compareMvViews.forEach(v => compareMvRender(v));
14334
14512
  else if (multiViewActive) mvViews.forEach(v => { mvDrawFrame(v); mvRender(v); });
14335
14513
  else if (qmriActive) qmriViews.forEach(v => qvRender(v));
14336
- else updateView();
14514
+ else { updateView(); return; }
14515
+ _debouncedHoverPrefetch();
14337
14516
  }
14338
14517
  });
14339
14518
  document.addEventListener('mouseup', (e) => {
@@ -14388,7 +14567,10 @@ h1{font-size:22px;border-bottom:1px solid #2c2c2c;padding-bottom:12px;}</style><
14388
14567
  qmriViews.forEach(v => qvRender(v));
14389
14568
  } else {
14390
14569
  updateView();
14570
+ saveState();
14571
+ return;
14391
14572
  }
14573
+ _debouncedHoverPrefetch();
14392
14574
  saveState();
14393
14575
  }, { passive: false });
14394
14576
 
@@ -733,11 +733,48 @@ def _write_vscode_signal(payload: dict, delay: float = 0.0, skip_compat: bool =
733
733
  uses_pid = _reg_data.get("fallbackId", False)
734
734
  except Exception:
735
735
  uses_pid = env_wid.isdigit()
736
+
737
+ # Guard against stale ARRAYVIEW_WINDOW_ID: when the extension
738
+ # host restarts (e.g. tunnel reconnection), the old process may
739
+ # still be alive but is no longer connected to a VS Code client.
740
+ # Detect this by looking for a newer registration from the same
741
+ # server (same first ppid — the tunnel/server parent process).
742
+ _env_ts = _reg_data.get("ts", 0)
743
+ _env_ppids = _reg_data.get("ppids", [])
744
+ try:
745
+ for _fname in os.listdir(signal_dir):
746
+ if not (_fname.startswith("window-") and _fname.endswith(".json")):
747
+ continue
748
+ _other_wid = _fname[7:-5]
749
+ if _other_wid == env_wid:
750
+ continue
751
+ with open(os.path.join(signal_dir, _fname)) as _f:
752
+ _other = json.load(_f)
753
+ _other_ts = _other.get("ts", 0)
754
+ _other_ppids = _other.get("ppids", [])
755
+ if (
756
+ _other_ts > _env_ts
757
+ and len(_env_ppids) >= 1
758
+ and len(_other_ppids) >= 1
759
+ and _env_ppids[0] == _other_ppids[0]
760
+ ):
761
+ _vprint(
762
+ f"[ArrayView] signal: ARRAYVIEW_WINDOW_ID={env_wid} is stale "
763
+ f"(newer registration {_other_wid} found), redirecting",
764
+ flush=True,
765
+ )
766
+ env_wid = _other_wid
767
+ _reg_data = _other
768
+ uses_pid = _other.get("fallbackId", False)
769
+ _env_ts = _other_ts
770
+ except Exception:
771
+ pass
772
+
736
773
  _prefix = "pid" if uses_pid else "ipc"
737
774
  filenames = (f"open-request-{_prefix}-{env_wid}.json",)
738
775
  targeted_via_env = True
739
776
  _vprint(
740
- f"[ArrayView] signal: ARRAYVIEW_WINDOW_ID={env_wid} → {filenames[0]}",
777
+ f"[ArrayView] signal: ARRAYVIEW_WINDOW_ID → {filenames[0]}",
741
778
  flush=True,
742
779
  )
743
780
 
@@ -868,6 +905,39 @@ def _write_vscode_signal(payload: dict, delay: float = 0.0, skip_compat: bool =
868
905
  _uses_pid = _reg_data.get("fallbackId", False)
869
906
  except Exception:
870
907
  _uses_pid = env_wid.isdigit()
908
+
909
+ # Same stale-env guard as the primary check above.
910
+ _env_ts = _reg_data.get("ts", 0)
911
+ _env_ppids = _reg_data.get("ppids", [])
912
+ try:
913
+ for _fname in os.listdir(signal_dir):
914
+ if not (_fname.startswith("window-") and _fname.endswith(".json")):
915
+ continue
916
+ _other_wid = _fname[7:-5]
917
+ if _other_wid == env_wid:
918
+ continue
919
+ with open(os.path.join(signal_dir, _fname)) as _f:
920
+ _other = json.load(_f)
921
+ _other_ts = _other.get("ts", 0)
922
+ _other_ppids = _other.get("ppids", [])
923
+ if (
924
+ _other_ts > _env_ts
925
+ and len(_env_ppids) >= 1
926
+ and len(_other_ppids) >= 1
927
+ and _env_ppids[0] == _other_ppids[0]
928
+ ):
929
+ _vprint(
930
+ f"[ArrayView] signal: remote env {env_wid} is stale "
931
+ f"(newer {_other_wid}), redirecting",
932
+ flush=True,
933
+ )
934
+ env_wid = _other_wid
935
+ _reg_data = _other
936
+ _uses_pid = _other.get("fallbackId", False)
937
+ _env_ts = _other_ts
938
+ except Exception:
939
+ pass
940
+
871
941
  _prefix = "pid" if _uses_pid else "ipc"
872
942
  _vprint(f"[ArrayView] signal: env window match → {_prefix}-{env_wid}", flush=True)
873
943
  filenames = (f"open-request-{_prefix}-{env_wid}.json",)
@@ -44,7 +44,7 @@ wheels = [
44
44
 
45
45
  [[package]]
46
46
  name = "arrayview"
47
- version = "0.12.1"
47
+ version = "0.12.2"
48
48
  source = { editable = "." }
49
49
  dependencies = [
50
50
  { name = "fastapi" },
@@ -824,6 +824,34 @@ function activate(context) {
824
824
 
825
825
  cleanupStaleFiles();
826
826
 
827
+ // Clean up stale registrations from previous tunnel sessions.
828
+ // When the extension host restarts (e.g. tunnel reconnect), the old
829
+ // process may still be alive but is no longer connected to a VS Code
830
+ // client. Detect by finding registrations with the same first ppid
831
+ // (same server/tunnel) but an older timestamp.
832
+ if (EXT_PPIDS.length >= 1) {
833
+ try {
834
+ const now = Date.now();
835
+ for (const f of fs.readdirSync(SIGNAL_DIR)) {
836
+ if (!f.startsWith('window-') || !f.endsWith('.json')) continue;
837
+ const wid = f.slice(7, -5);
838
+ if (wid === windowId) continue;
839
+ try {
840
+ const data = JSON.parse(fs.readFileSync(path.join(SIGNAL_DIR, f), 'utf8'));
841
+ const sameTunnel = data.ppids && data.ppids.length >= 1 &&
842
+ data.ppids[0] === EXT_PPIDS[0];
843
+ if (sameTunnel && data.ts && data.ts < now - 5000) {
844
+ fs.unlinkSync(path.join(SIGNAL_DIR, f));
845
+ log(`CLEANUP: removed stale same-tunnel registration ${f} (ts=${data.ts})`);
846
+ // Also remove any stale signal files targeting that window
847
+ const prefix = data.fallbackId ? 'pid' : 'ipc';
848
+ try { fs.unlinkSync(path.join(SIGNAL_DIR, `open-request-${prefix}-${wid}.json`)); } catch (_) {}
849
+ }
850
+ } catch (_) {}
851
+ }
852
+ } catch (_) {}
853
+ }
854
+
827
855
  void tryOpenSignalFile();
828
856
 
829
857
  const interval = setInterval(() => void tryOpenSignalFile(), 1000);
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes