arrayview 0.11.0__tar.gz → 0.12.1__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 (75) hide show
  1. {arrayview-0.11.0 → arrayview-0.12.1}/AGENTS.md +15 -3
  2. arrayview-0.12.1/CONTRIBUTING.md +93 -0
  3. arrayview-0.12.1/PKG-INFO +60 -0
  4. arrayview-0.12.1/README.md +25 -0
  5. {arrayview-0.11.0 → arrayview-0.12.1}/pyproject.toml +1 -1
  6. arrayview-0.12.1/scripts/release.sh +138 -0
  7. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/ARCHITECTURE.md +8 -2
  8. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_io.py +65 -0
  9. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_launcher.py +5 -2
  10. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_render.py +6 -2
  11. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_server.py +149 -20
  12. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_session.py +16 -1
  13. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_shell.html +28 -10
  14. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_stdio_server.py +5 -2
  15. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_viewer.html +3240 -2092
  16. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_vscode.py +80 -25
  17. arrayview-0.12.1/tests/test_command_reachability.py +120 -0
  18. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_mode_roundtrip.py +0 -1
  19. arrayview-0.12.1/tests/test_nifti_meta.py +59 -0
  20. {arrayview-0.11.0 → arrayview-0.12.1}/uv.lock +1 -1
  21. arrayview-0.11.0/.tmp-vsix/extension/extension.js +0 -343
  22. arrayview-0.11.0/.tmp-vsix/extension/package.json +0 -13
  23. arrayview-0.11.0/PKG-INFO +0 -137
  24. arrayview-0.11.0/README.md +0 -102
  25. arrayview-0.11.0/scripts/release.sh +0 -78
  26. {arrayview-0.11.0 → arrayview-0.12.1}/.claude/skills/invocation-consistency/SKILL.md +0 -0
  27. {arrayview-0.11.0 → arrayview-0.12.1}/.claude/skills/modes-consistency/SKILL.md +0 -0
  28. {arrayview-0.11.0 → arrayview-0.12.1}/.claude/skills/ui-consistency-audit/SKILL.md +0 -0
  29. {arrayview-0.11.0 → arrayview-0.12.1}/.claude/skills/viewer-ui-checklist/SKILL.md +0 -0
  30. {arrayview-0.11.0 → arrayview-0.12.1}/.claude/skills/visual-bug-fixing/SKILL.md +0 -0
  31. {arrayview-0.11.0 → arrayview-0.12.1}/.github/workflows/docs.yml +0 -0
  32. {arrayview-0.11.0 → arrayview-0.12.1}/.github/workflows/python-publish.yml +0 -0
  33. {arrayview-0.11.0 → arrayview-0.12.1}/.gitignore +0 -0
  34. {arrayview-0.11.0 → arrayview-0.12.1}/.python-version +0 -0
  35. {arrayview-0.11.0 → arrayview-0.12.1}/LICENSE +0 -0
  36. {arrayview-0.11.0 → arrayview-0.12.1}/docs/comparing.md +0 -0
  37. {arrayview-0.11.0 → arrayview-0.12.1}/docs/configuration.md +0 -0
  38. {arrayview-0.11.0 → arrayview-0.12.1}/docs/display.md +0 -0
  39. {arrayview-0.11.0 → arrayview-0.12.1}/docs/index.md +0 -0
  40. {arrayview-0.11.0 → arrayview-0.12.1}/docs/loading.md +0 -0
  41. {arrayview-0.11.0 → arrayview-0.12.1}/docs/logo.png +0 -0
  42. {arrayview-0.11.0 → arrayview-0.12.1}/docs/measurement.md +0 -0
  43. {arrayview-0.11.0 → arrayview-0.12.1}/docs/remote.md +0 -0
  44. {arrayview-0.11.0 → arrayview-0.12.1}/docs/stylesheets/extra.css +0 -0
  45. {arrayview-0.11.0 → arrayview-0.12.1}/docs/viewing.md +0 -0
  46. {arrayview-0.11.0 → arrayview-0.12.1}/matlab/arrayview.m +0 -0
  47. {arrayview-0.11.0 → arrayview-0.12.1}/mkdocs.yml +0 -0
  48. {arrayview-0.11.0 → arrayview-0.12.1}/plans/webview/LOG.md +0 -0
  49. {arrayview-0.11.0 → arrayview-0.12.1}/scripts/demo.py +0 -0
  50. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/__init__.py +0 -0
  51. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/__main__.py +0 -0
  52. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_app.py +0 -0
  53. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_config.py +0 -0
  54. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_icon.png +0 -0
  55. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_platform.py +0 -0
  56. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_segmentation.py +0 -0
  57. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/_torch.py +0 -0
  58. {arrayview-0.11.0 → arrayview-0.12.1}/src/arrayview/arrayview-opener.vsix +0 -0
  59. {arrayview-0.11.0 → arrayview-0.12.1}/tests/conftest.py +0 -0
  60. {arrayview-0.11.0 → arrayview-0.12.1}/tests/make_vectorfield_test_arrays.py +0 -0
  61. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_api.py +0 -0
  62. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_browser.py +0 -0
  63. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_cli.py +0 -0
  64. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_config.py +0 -0
  65. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_interactions.py +0 -0
  66. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_large_arrays.py +0 -0
  67. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_mode_consistency.py +0 -0
  68. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_mode_matrix.py +0 -0
  69. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_rgb_pixel_art.py +0 -0
  70. {arrayview-0.11.0 → arrayview-0.12.1}/tests/test_torch.py +0 -0
  71. {arrayview-0.11.0 → arrayview-0.12.1}/tests/ui_audit.py +0 -0
  72. {arrayview-0.11.0 → arrayview-0.12.1}/tests/visual_smoke.py +0 -0
  73. {arrayview-0.11.0 → arrayview-0.12.1}/vscode-extension/LICENSE +0 -0
  74. {arrayview-0.11.0 → arrayview-0.12.1}/vscode-extension/extension.js +0 -0
  75. {arrayview-0.11.0 → arrayview-0.12.1}/vscode-extension/package.json +0 -0
@@ -2,6 +2,9 @@ Read `src/arrayview/ARCHITECTURE.md` for codebase orientation.
2
2
 
3
3
  # ArrayView
4
4
 
5
+ I haven't written or read a single line of code in src so when you ask me questions/input,
6
+ keep it simple with some simple examples.
7
+
5
8
  ## Skills
6
9
 
7
10
  Load the relevant skill before touching the corresponding area.
@@ -24,6 +27,13 @@ Load the relevant skill before touching the corresponding area.
24
27
  - For visual/animation features, propose 2-3 options BEFORE implementing
25
28
  - UI visibility changes go through reconcilers (`_reconcileUI`/`_reconcileLayout`/`_reconcileCompareState`/`_reconcileCbVisibility`), not inline `style.display` or `classList` toggles in mode functions
26
29
  - All colorbar state (animation, window/level, hover, drag) flows through `primaryCb` ColorBar instance — never read/write legacy globals. Multiview colorbars sync via `primaryCb`.
30
+ - Keybinds flow through the command registry (`commands` / `keybinds` in `_viewer.html`), not inline keydown branches. The help overlay auto-generates from command `title` fields — do not hand-edit it.
31
+
32
+ ## Contributing
33
+
34
+ Before creating a PR or making user-facing changes, read `CONTRIBUTING.md`. It defines the design
35
+ language, keybinding conventions, overlay/popup patterns, and testing requirements. All PRs that
36
+ touch the viewer must follow it.
27
37
 
28
38
  ## Execution
29
39
 
@@ -32,9 +42,11 @@ Always use **subagent-driven development** for implementation. Commit completed
32
42
  ## Testing
33
43
 
34
44
  ```bash
35
- uv run pytest tests/test_api.py -v # HTTP API
36
- uv run pytest tests/test_browser.py -v # Playwright
37
- uv run python tests/visual_smoke.py # screenshots
45
+ uv run pytest tests/test_api.py -v # HTTP API
46
+ uv run pytest tests/test_browser.py -v # Playwright
47
+ uv run pytest tests/test_mode_roundtrip.py -v # mode state round-trip
48
+ uv run pytest tests/test_command_reachability.py -v # command when-clause matrix
49
+ uv run python tests/visual_smoke.py # screenshots
38
50
  ```
39
51
 
40
52
  After any UI change, use `/ui-consistency-audit` to verify across all modes.
@@ -0,0 +1,93 @@
1
+ # Contributing to arrayview
2
+
3
+ Thanks for your interest. This guide keeps things consistent as more people
4
+ contribute.
5
+
6
+ ## Proposing changes
7
+
8
+ For anything user-facing (new shortcut, overlay, layout change), **open an
9
+ issue first**. Include:
10
+
11
+ - What it does, in one sentence
12
+ - Which key triggers it (if any)
13
+ - Which modes it affects (Normal, Multi-view, Compare, Diff, Registration, qMRI)
14
+ - A rough sketch or description of how it looks
15
+
16
+ Bug fixes and internal refactors can go straight to a PR.
17
+
18
+ ## Design principles
19
+
20
+ 1. **Array fills the screen.** Minimize chrome. UI elements stay hidden or
21
+ dimmed until the user hovers or presses a key, then fade back out.
22
+
23
+ 2. **Monospace only.** All text uses the system monospace stack
24
+ (`'SF Mono', ui-monospace, 'Cascadia Code', 'JetBrains Mono', monospace`).
25
+ Never use sans-serif.
26
+
27
+ 3. **Colors via CSS custom properties.** Use `var(--surface)`, `var(--text)`,
28
+ `var(--active-dim)`, etc. Never hardcode hex values. The viewer ships four
29
+ themes (dark, light, solarized, nord) and all must work.
30
+
31
+ 4. **Yellow for active state.** `--active-dim` (#f5c842 in dark theme) marks
32
+ the currently active element. Don't introduce new accent colors.
33
+
34
+ 5. **All six modes.** Every visual feature must be tested across Normal,
35
+ Multi-view (V/v), Compare (B/P), Diff (X), Registration (R), and qMRI (q).
36
+ If your feature only applies to some modes, add explicit mode guards.
37
+
38
+ ## Keyboard shortcuts
39
+
40
+ - Check the existing shortcut table (press `?` in the viewer) before picking a
41
+ key. Conflicts will be caught in review but save yourself the round-trip.
42
+ - Single lowercase letters are scarce. Prefer Shift+key or a modifier for new
43
+ features.
44
+ - If a shortcut only makes sense in certain modes, guard it:
45
+ ```js
46
+ if (currentMode !== 'compare') return;
47
+ ```
48
+ - Document the new shortcut in the help overlay (`#help-overlay` in
49
+ `_viewer.html`).
50
+
51
+ ## Popup menus and overlays
52
+
53
+ Several proposals involve popup/context menus. To keep them visually
54
+ consistent:
55
+
56
+ - Background: `var(--surface)`, border: `1px solid var(--border)`,
57
+ border-radius: `var(--radius-lg)`.
58
+ - Dismiss on **Escape** and on clicking outside the popup.
59
+ - No permanent visibility -- show on trigger, hide when done.
60
+ - Keep text small (12-13px) and monospace.
61
+ - Use `var(--active-dim)` for the selected/hovered item, `var(--text)` for
62
+ normal items, `var(--muted)` for secondary info.
63
+ - Animate in with a short opacity+scale transition, not an instant pop.
64
+
65
+ Look at `#uni-picker-box` in `_viewer.html` for a reference implementation.
66
+
67
+ ## Testing checklist
68
+
69
+ Before submitting a PR:
70
+
71
+ - [ ] `uv run pytest tests/test_api.py -x` passes
72
+ - [ ] `uv run python tests/visual_smoke.py` passes
73
+ - [ ] If you added UI, update `tests/visual_smoke.py` to cover it
74
+ - [ ] Manually verify in all affected modes (at minimum: Normal + one
75
+ multi-pane mode)
76
+ - [ ] New shortcuts are documented in the help overlay
77
+
78
+ ## Dev setup
79
+
80
+ ```bash
81
+ git clone <repo-url>
82
+ cd arrayview
83
+ uv sync
84
+ uv run arrayview tests/ # launch with test data
85
+ ```
86
+
87
+ ## Style notes
88
+
89
+ - The frontend lives in a single file: `src/arrayview/_viewer.html`.
90
+ HTML, CSS, and JS are all in there. Keep it that way.
91
+ - Python backend uses `uv` for package management.
92
+ - Commit messages follow conventional commits (`feat:`, `fix:`, `refactor:`,
93
+ etc.).
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: arrayview
3
+ Version: 0.12.1
4
+ Summary: Fast multi-dimensional array viewer
5
+ Project-URL: Home, https://github.com/oscarvanderheide/arrayview
6
+ Project-URL: Source, https://github.com/oscarvanderheide/arrayview
7
+ Author-email: Oscar <oscarvanderheide@example.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: array,mri,npy,viewer,visualization
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Python: >=3.12
17
+ Requires-Dist: fastapi>=0.129.0
18
+ Requires-Dist: h5py>=3.0
19
+ Requires-Dist: matplotlib>=3.9.0
20
+ Requires-Dist: nibabel>=5.3.3
21
+ Requires-Dist: numpy>=2.4.2
22
+ Requires-Dist: pillow>=12.1.1
23
+ Requires-Dist: pyqt5>=5.15; sys_platform == 'linux'
24
+ Requires-Dist: pyqtwebengine>=5.15; sys_platform == 'linux'
25
+ Requires-Dist: python-multipart>=0.0.22
26
+ Requires-Dist: pywebview>=6.1
27
+ Requires-Dist: qmricolors>=0.1.2
28
+ Requires-Dist: qtpy>=2.0; sys_platform == 'linux'
29
+ Requires-Dist: scipy>=1.10
30
+ Requires-Dist: tifffile>=2023.1.1
31
+ Requires-Dist: uvicorn>=0.41.0
32
+ Requires-Dist: websockets>=14.0
33
+ Requires-Dist: zarr>=2.17
34
+ Description-Content-Type: text/markdown
35
+
36
+ # <img src="docs/logo.png" height="36"> arrayview
37
+
38
+ This is what looking at arrays should feel like.
39
+
40
+ Load up `.npy`, `.nii`, `.h5`, `.mat` and friends. Works locally, in Jupyter, over SSH and VS Code tunnels. Press `?` once you're in.
41
+
42
+ ## CLI
43
+
44
+ ```bash
45
+ uvx arrayview your_array.npy
46
+ ```
47
+
48
+ ## Python
49
+
50
+ ```bash
51
+ uv add arrayview
52
+ ```
53
+
54
+ ```python
55
+ from arrayview import view
56
+ view(arr)
57
+ ```
58
+
59
+ [docs →](https://oscarvanderheide.github.io/arrayview/)
60
+
@@ -0,0 +1,25 @@
1
+ # <img src="docs/logo.png" height="36"> arrayview
2
+
3
+ This is what looking at arrays should feel like.
4
+
5
+ Load up `.npy`, `.nii`, `.h5`, `.mat` and friends. Works locally, in Jupyter, over SSH and VS Code tunnels. Press `?` once you're in.
6
+
7
+ ## CLI
8
+
9
+ ```bash
10
+ uvx arrayview your_array.npy
11
+ ```
12
+
13
+ ## Python
14
+
15
+ ```bash
16
+ uv add arrayview
17
+ ```
18
+
19
+ ```python
20
+ from arrayview import view
21
+ view(arr)
22
+ ```
23
+
24
+ [docs →](https://oscarvanderheide.github.io/arrayview/)
25
+
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arrayview"
7
- version = "0.11.0"
7
+ version = "0.12.1"
8
8
  description = "Fast multi-dimensional array viewer"
9
9
  readme = { file = "README.md", content-type = "text/markdown" }
10
10
  requires-python = ">=3.12"
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # release.sh — bump version, commit, tag, push, create GitHub release
6
+ # ---------------------------------------------------------------------------
7
+
8
+ BUMP="minor"
9
+ DRY_RUN=true
10
+ NO_AI=false
11
+
12
+ usage() {
13
+ cat <<EOF
14
+ Usage: $(basename "$0") [OPTIONS]
15
+
16
+ Options:
17
+ --bump {major,minor,patch} Version bump type (default: minor)
18
+ --execute Actually run (default is dry-run)
19
+ --no-ai Skip AI release notes, use GitHub's --generate-notes
20
+ -h, --help Show this help
21
+ EOF
22
+ }
23
+
24
+ while [[ $# -gt 0 ]]; do
25
+ case "$1" in
26
+ --bump) BUMP="$2"; shift 2 ;;
27
+ --execute) DRY_RUN=false; shift ;;
28
+ --no-ai) NO_AI=true; shift ;;
29
+ -h|--help) usage; exit 0 ;;
30
+ *) echo "Unknown option: $1"; usage; exit 1 ;;
31
+ esac
32
+ done
33
+
34
+ if [[ ! "$BUMP" =~ ^(major|minor|patch)$ ]]; then
35
+ echo "Error: --bump must be major, minor, or patch (got '$BUMP')"
36
+ exit 1
37
+ fi
38
+
39
+ # --- Guard: clean working tree on main ---
40
+ branch=$(git rev-parse --abbrev-ref HEAD)
41
+ if [[ "$branch" != "main" ]]; then
42
+ echo "Error: must be on main (currently on '$branch')"
43
+ exit 1
44
+ fi
45
+
46
+ if ! git diff --quiet || ! git diff --cached --quiet; then
47
+ echo "Error: working tree is dirty — commit or stash first"
48
+ exit 1
49
+ fi
50
+
51
+ # --- Bump version ---
52
+ uv version --bump "$BUMP"
53
+ VERSION=$(uv version --short)
54
+ TAG="v${VERSION}"
55
+
56
+ echo "Version bumped to $VERSION (tag: $TAG)"
57
+
58
+ uv lock --quiet
59
+
60
+ run() {
61
+ echo "+ $*"
62
+ if [[ "$DRY_RUN" == true ]]; then
63
+ return
64
+ fi
65
+ "$@"
66
+ }
67
+
68
+ # --- Generate release notes ---
69
+ CLAUDE_BIN="${CLAUDE_BIN:-/Users/oscar/.local/bin/claude}"
70
+ PREV_TAG=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || echo "")
71
+ NOTES=""
72
+
73
+ if [[ -n "$PREV_TAG" ]]; then
74
+ COMMITS=$(git log "${PREV_TAG}..HEAD" --oneline)
75
+ else
76
+ COMMITS=$(git log --oneline -20)
77
+ fi
78
+
79
+ if [[ "$NO_AI" == false ]] && command -v "$CLAUDE_BIN" &>/dev/null; then
80
+ echo "Generating release notes with Claude..."
81
+ NOTES=$("$CLAUDE_BIN" -p "You are writing release notes for arrayview $TAG (a Python array/image viewer).
82
+
83
+ Here are the commits since the last release ($PREV_TAG):
84
+
85
+ $COMMITS
86
+
87
+ Write concise, user-friendly release notes in this exact format:
88
+
89
+ ## What's new in $TAG
90
+
91
+ - **Feature name**: one-sentence description
92
+
93
+ Rules:
94
+ - 5-10 bullet points max — group related commits into one bullet
95
+ - Write for end-users, not developers (no commit hashes, no file names)
96
+ - Use past tense (\"added\", \"fixed\", \"improved\")
97
+ - Skip pure refactors/docs unless they affect user experience
98
+ - Bold the feature name, keep the description to one sentence" 2>/dev/null) || true
99
+ fi
100
+
101
+ if [[ -z "$NOTES" ]]; then
102
+ if [[ "$NO_AI" == false ]]; then
103
+ echo "AI notes unavailable, falling back to --generate-notes"
104
+ fi
105
+ fi
106
+
107
+ if [[ "$DRY_RUN" == true && -n "$NOTES" ]]; then
108
+ echo ""
109
+ echo "--- Release notes preview ---"
110
+ echo "$NOTES"
111
+ echo "-----------------------------"
112
+ echo ""
113
+ fi
114
+
115
+ # --- Commit, tag, push, release ---
116
+ run git add pyproject.toml uv.lock
117
+ run git commit -m "release: $TAG"
118
+ run git push origin main
119
+ run git tag "$TAG"
120
+ run git push origin "$TAG"
121
+
122
+ if [[ -n "$NOTES" ]]; then
123
+ run gh release create "$TAG" \
124
+ --title "$TAG" \
125
+ --notes "$NOTES" \
126
+ --prerelease
127
+ else
128
+ run gh release create "$TAG" \
129
+ --title "$TAG" \
130
+ --generate-notes \
131
+ --prerelease
132
+ fi
133
+
134
+ if [[ "$DRY_RUN" == true ]]; then
135
+ echo ""
136
+ echo "(dry-run — re-run with --execute to apply)"
137
+ git checkout pyproject.toml uv.lock
138
+ fi
@@ -52,7 +52,7 @@ Detection logic lives in `_platform.py`. Display opening logic lives in `_launch
52
52
  | `_stdio_server.py` | 791 | Stdio transport for VS Code direct webview — JSON stdin, binary stdout |
53
53
  | `_torch.py` | 217 | PyTorch integration: `view_batch()`, `TrainingMonitor` (lazy torch import) |
54
54
  | `_vscode.py` | 1014 | VS Code extension install/management, signal-file IPC, shared-memory IPC, browser opening |
55
- | `_viewer.html` | 14480 | **The entire frontend** — CSS + JS in a single file, all viewing modes |
55
+ | `_viewer.html` | 15600 | **The entire frontend** — CSS + JS in a single file, all viewing modes |
56
56
  | `_shell.html` | 174 | Tab-bar shell for native pywebview — wraps viewer iframes, manages multi-tab sessions |
57
57
 
58
58
  ## Frontend (_viewer.html)
@@ -89,7 +89,7 @@ The frontend is a single self-contained HTML file (~15k lines). No build step, n
89
89
  | Rendering Pipeline | `updateView()`, play/animate, screenshot capture |
90
90
  | ROI and Selection Modes | Rectangle/ellipse ROI drawing, statistics computation |
91
91
  | nnInteractive Segmentation | Click-to-segment UI, mask overlay, undo stack |
92
- | Keyboard Shortcuts | All hotkey bindings single master switch/case block |
92
+ | Keyboard Shortcuts | Command registry (`commands` / `keybinds` / `makeContext` / `evalWhen` / `dispatchCommand`) + `/`-triggered command palette. Keydown handler is a thin dispatcher prefix |
93
93
  | Mode Transitions | Compare/multiview/qMRI enter/exit, crosshair animation |
94
94
  | Scroll, Zoom, and Pan | Mouse wheel slice scroll, pinch zoom, scroll-to-zoom |
95
95
  | Immersive Mode, Cross-Fade, and Visual Effects | Zen mode, fullscreen (K), animated transitions |
@@ -136,6 +136,9 @@ Pill-shaped badges below the canvas showing active visualization transforms. **C
136
136
  ### Dynamic Islands
137
137
  Floating UI panels that appear/disappear based on context: ROI statistics, segmentation controls, colorbar hover, dimension sliders. Must be tested across all viewing modes (normal, immersive, multiview, compare).
138
138
 
139
+ ### Command Registry
140
+ All keybinds flow through a VS Code-style command registry in `_viewer.html`. Three tables: `commands` (id → `{title, when, run}`), `keybinds` (key+modifiers → command id), and `makeContext(state)` (mode/state flag bag). `dispatchCommand(e)` is wired as a prefix to the keydown handler; on a match it evaluates `when` against the context and runs the command, otherwise falls through. The help overlay is auto-generated from command `title` fields — never hand-edit it. A `/`-triggered command palette fuzzy-searches all commands. Cross-mode enablement is guarded by `tests/test_command_reachability.py`.
141
+
139
142
  ### Reconcilers
140
143
  Functions in the "UI Validation and Reconciliation" section (~line 13666) that enforce consistent UI state. When mode changes happen, reconcilers update visibility of containers, colorbars, dynamic islands, and compare sub-mode UI. There are four:
141
144
  1. **Unified UI reconciler** — master state enforcer
@@ -156,6 +159,9 @@ A dedicated daemon thread (`_session.py`) runs all CPU-heavy rendering off the a
156
159
  ### Dynamic Islands
157
160
  Must verify island positioning and visibility across normal, immersive, multiview, and compare modes. Islands use absolute/fixed positioning that breaks if parent containers change.
158
161
 
162
+ ### Keybind Changes
163
+ Keybinds live in the `commands` + `keybinds` tables, not in the keydown handler. Adding or changing a keybind means editing those tables and (if needed) extending `makeContext` / `evalWhen`. The help overlay regenerates itself from command `title` fields — do not edit overlay HTML directly.
164
+
159
165
  ### Layout Debugging
160
166
  When debugging layout issues: identify the root cause (which scale function, which reconciler, which CSS rule) before applying fixes. Symptoms in one mode often originate from shared code affecting all modes.
161
167
 
@@ -124,6 +124,71 @@ def _select_npz_array(npz, filepath):
124
124
  print(f" Please enter a number between 1 and {len(entries)}.")
125
125
 
126
126
 
127
+ def _load_nifti_with_meta(filepath):
128
+ """Load a NIfTI file, canonical-reorient, return (array, meta).
129
+
130
+ meta is a dict with keys:
131
+ affine : original 4x4 affine (RAS+ mm)
132
+ affine_canonical : 4x4 affine after as_closest_canonical
133
+ voxel_sizes : tuple (sx, sy, sz) in mm, post-reorient
134
+ axis_labels : tuple of 3 strs from {"R","L","A","P","S","I"}
135
+ — positive direction of each canonical axis
136
+ is_oblique : bool — True if rotation part has off-diagonal magnitude > 1e-3
137
+ after normalizing voxel sizes
138
+ """
139
+ nib = _nib()
140
+ img = nib.load(filepath)
141
+ original_affine = np.asarray(img.affine, dtype=np.float64)
142
+ canon = nib.as_closest_canonical(img)
143
+ affine_canonical = np.asarray(canon.affine, dtype=np.float64)
144
+
145
+ # NOTE: reorient requires materializing axis permutes/flips. .nii.gz is
146
+ # already eager (gzip not seekable), so this is free; .nii loses mmap as a
147
+ # necessary cost to apply the reorient.
148
+ arr = np.asarray(canon.dataobj)
149
+
150
+ rot = affine_canonical[:3, :3]
151
+ voxel_sizes = tuple(float(np.linalg.norm(rot[:, i])) for i in range(3))
152
+
153
+ # Direction of each canonical axis (sign of diagonal after normalizing)
154
+ norm_rot = np.zeros((3, 3))
155
+ for i in range(3):
156
+ if voxel_sizes[i] > 0:
157
+ norm_rot[:, i] = rot[:, i] / voxel_sizes[i]
158
+ pos_labels = ("R", "A", "S")
159
+ neg_labels = ("L", "P", "I")
160
+ axis_labels = tuple(
161
+ pos_labels[i] if norm_rot[i, i] >= 0 else neg_labels[i] for i in range(3)
162
+ )
163
+
164
+ # Oblique = off-diagonal of normalized rotation has |val| > tol
165
+ off_diag_max = 0.0
166
+ for i in range(3):
167
+ for j in range(3):
168
+ if i != j:
169
+ off_diag_max = max(off_diag_max, abs(norm_rot[i, j]))
170
+ is_oblique = bool(off_diag_max > 1e-3)
171
+
172
+ meta = {
173
+ "affine": original_affine,
174
+ "affine_canonical": affine_canonical,
175
+ "voxel_sizes": voxel_sizes,
176
+ "axis_labels": axis_labels,
177
+ "is_oblique": is_oblique,
178
+ }
179
+ return arr, meta
180
+
181
+
182
+ def load_data_with_meta(filepath):
183
+ """Like load_data but also returns spatial metadata for NIfTI files.
184
+
185
+ Returns (array, meta_or_None). meta is None for non-NIfTI formats.
186
+ """
187
+ if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
188
+ return _load_nifti_with_meta(filepath)
189
+ return load_data(filepath), None
190
+
191
+
127
192
  def load_data(filepath):
128
193
  if filepath.endswith(".npy"):
129
194
  return np.load(filepath, mmap_mode="r")
@@ -1654,10 +1654,10 @@ def _serve_daemon(
1654
1654
  ).start()
1655
1655
 
1656
1656
  def _load():
1657
- from arrayview._io import load_data
1657
+ from arrayview._io import load_data, load_data_with_meta
1658
1658
 
1659
1659
  try:
1660
- data = load_data(filepath)
1660
+ data, spatial_meta = load_data_with_meta(filepath)
1661
1661
  if cleanup:
1662
1662
  try:
1663
1663
  os.unlink(filepath)
@@ -1667,6 +1667,9 @@ def _serve_daemon(
1667
1667
  data, filepath=None if cleanup else filepath, name=name
1668
1668
  )
1669
1669
  session.sid = sid
1670
+ session.spatial_meta = spatial_meta
1671
+ if spatial_meta is not None:
1672
+ session.original_volume = data
1670
1673
  if rgb:
1671
1674
  from arrayview._render import _setup_rgb
1672
1675
 
@@ -667,10 +667,12 @@ def render_mosaic(
667
667
  complex_mode=0,
668
668
  log_scale=False,
669
669
  mosaic_cols=None,
670
+ vmin_override=None,
671
+ vmax_override=None,
670
672
  ):
671
673
  idx_norm = list(idx_tuple)
672
674
  idx_norm[dim_z] = 0
673
- key = (dim_x, dim_y, dim_z, tuple(idx_norm), colormap, dr, complex_mode, log_scale, mosaic_cols)
675
+ key = (dim_x, dim_y, dim_z, tuple(idx_norm), colormap, dr, complex_mode, log_scale, mosaic_cols, vmin_override, vmax_override)
674
676
  if key in session.mosaic_cache:
675
677
  session.mosaic_cache.move_to_end(key)
676
678
  return session.mosaic_cache[key]
@@ -690,7 +692,9 @@ def render_mosaic(
690
692
  frames = [np.log1p(np.abs(f)).astype(np.float32) for f in frames]
691
693
  all_data = np.stack(frames)
692
694
 
693
- if complex_mode == 1 and np.iscomplexobj(session.data):
695
+ if vmin_override is not None and vmax_override is not None:
696
+ vmin, vmax = float(vmin_override), float(vmax_override)
697
+ elif complex_mode == 1 and np.iscomplexobj(session.data):
694
698
  vmin, vmax = -float(np.pi), float(np.pi)
695
699
  else:
696
700
  vmin = float(np.percentile(all_data, 1))