arrayview 0.33.1__tar.gz → 0.35.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.33.1 → arrayview-0.35.0}/PKG-INFO +1 -1
  2. {arrayview-0.33.1 → arrayview-0.35.0}/pyproject.toml +1 -1
  3. arrayview-0.35.0/src/arrayview/_config.py +352 -0
  4. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_launcher.py +48 -36
  5. arrayview-0.35.0/src/arrayview/_routes_preferences.py +114 -0
  6. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_server.py +19 -2
  7. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_viewer.html +400 -17
  8. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode_extension.py +1 -1
  9. arrayview-0.35.0/src/arrayview/arrayview-opener.vsix +0 -0
  10. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_api.py +174 -0
  11. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_config.py +72 -0
  12. arrayview-0.35.0/tests/test_preferences_ui.py +185 -0
  13. arrayview-0.33.1/src/arrayview/_config.py +0 -135
  14. arrayview-0.33.1/src/arrayview/arrayview-opener.vsix +0 -0
  15. {arrayview-0.33.1 → arrayview-0.35.0}/.gitignore +0 -0
  16. {arrayview-0.33.1 → arrayview-0.35.0}/LICENSE +0 -0
  17. {arrayview-0.33.1 → arrayview-0.35.0}/README.md +0 -0
  18. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/ARCHITECTURE.md +0 -0
  19. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/__init__.py +0 -0
  20. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/__main__.py +0 -0
  21. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_analysis.py +0 -0
  22. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_app.py +0 -0
  23. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_codex_open.py +0 -0
  24. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_dicom.py +0 -0
  25. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_diff.py +0 -0
  26. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_icon.png +0 -0
  27. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_imaging.py +0 -0
  28. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_instance_registry.py +0 -0
  29. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_io.py +0 -0
  30. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_launch_plan.py +0 -0
  31. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_launch_trace.py +0 -0
  32. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_lifecycle.py +0 -0
  33. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_overlays.py +0 -0
  34. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_platform.py +0 -0
  35. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_render.py +0 -0
  36. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_analysis.py +0 -0
  37. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_drop.py +0 -0
  38. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_export.py +0 -0
  39. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_loading.py +0 -0
  40. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_persistence.py +0 -0
  41. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_preload.py +0 -0
  42. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_query.py +0 -0
  43. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_rendering.py +0 -0
  44. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_segmentation.py +0 -0
  45. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_state.py +0 -0
  46. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_vectorfield.py +0 -0
  47. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_routes_websocket.py +0 -0
  48. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_segmentation.py +0 -0
  49. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_session.py +0 -0
  50. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_session_spec.py +0 -0
  51. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_shell.html +0 -0
  52. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_synthetic_mri.py +0 -0
  53. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_torch.py +0 -0
  54. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vectorfield.py +0 -0
  55. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode.py +0 -0
  56. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode_browser.py +0 -0
  57. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/_vscode_signal.py +0 -0
  58. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/gsap.min.js +0 -0
  59. {arrayview-0.33.1 → arrayview-0.35.0}/src/arrayview/todo.md +0 -0
  60. {arrayview-0.33.1 → arrayview-0.35.0}/tests/capture_patient_loading.py +0 -0
  61. {arrayview-0.33.1 → arrayview-0.35.0}/tests/capture_v_animation.py +0 -0
  62. {arrayview-0.33.1 → arrayview-0.35.0}/tests/conftest.py +0 -0
  63. {arrayview-0.33.1 → arrayview-0.35.0}/tests/lifecycle_matrix.py +0 -0
  64. {arrayview-0.33.1 → arrayview-0.35.0}/tests/make_vectorfield_test_arrays.py +0 -0
  65. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_backend_shared.py +0 -0
  66. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_browser.py +0 -0
  67. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_cli.py +0 -0
  68. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_codex_open.py +0 -0
  69. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_colorbar_hover_highlight.py +0 -0
  70. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_command_reachability.py +0 -0
  71. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_cross_mode_parametrized.py +0 -0
  72. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_dicom.py +0 -0
  73. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_display_opener_results.py +0 -0
  74. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_doctor_commands.py +0 -0
  75. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_drop_import.py +0 -0
  76. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_instance_registry.py +0 -0
  77. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_interactions.py +0 -0
  78. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_invocation_contract_matrix.py +0 -0
  79. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_io.py +0 -0
  80. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_large_arrays.py +0 -0
  81. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_blackbox.py +0 -0
  82. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_diagnostics.py +0 -0
  83. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_execution_ownership.py +0 -0
  84. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_plan.py +0 -0
  85. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_launch_trace.py +0 -0
  86. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_lifecycle_contract.py +0 -0
  87. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_loading_server.py +0 -0
  88. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_mode_consistency.py +0 -0
  89. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_mode_matrix.py +0 -0
  90. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_mode_roundtrip.py +0 -0
  91. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_nifti_meta.py +0 -0
  92. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_nifti_series.py +0 -0
  93. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_public_invocation_regressions.py +0 -0
  94. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_rgb_pixel_art.py +0 -0
  95. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_safe_server_stop.py +0 -0
  96. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_server_identity_status.py +0 -0
  97. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_session_spec.py +0 -0
  98. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_spawn_ownership.py +0 -0
  99. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_torch.py +0 -0
  100. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_component_integration.py +0 -0
  101. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_component_unit.py +0 -0
  102. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_handle_lifecycle.py +0 -0
  103. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_view_launch_execution.py +0 -0
  104. {arrayview-0.33.1 → arrayview-0.35.0}/tests/test_vscode_ack_protocol.py +0 -0
  105. {arrayview-0.33.1 → arrayview-0.35.0}/tests/ui_audit.py +0 -0
  106. {arrayview-0.33.1 → arrayview-0.35.0}/tests/v_anim_frames/.gitkeep +0 -0
  107. {arrayview-0.33.1 → arrayview-0.35.0}/tests/visual_smoke.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arrayview
3
- Version: 0.33.1
3
+ Version: 0.35.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.33.1"
7
+ version = "0.35.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"
@@ -0,0 +1,352 @@
1
+ """Read/write ~/.arrayview/config.toml for persistent user preferences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as _datetime
6
+ import math
7
+ import os
8
+ import tempfile
9
+ import threading
10
+ from copy import deepcopy
11
+
12
+ CONFIG_PATH = os.path.expanduser("~/.arrayview/config.toml")
13
+
14
+ _VALID_WINDOW_MODES = {"browser", "vscode", "native", "inline", "none"}
15
+ _VALID_ENV_KEYS = {"default", "terminal", "vscode", "jupyter", "ssh", "julia"}
16
+ _VALID_THEMES = {"dark", "light"}
17
+ _VALID_ORTHO_LAYOUTS = {"horizontal", "big-left"}
18
+ _VALID_DIMBAR_MODES = {"compact", "extended"}
19
+ _CONFIG_LOCK = threading.Lock()
20
+
21
+ WINDOW_PREFERENCE_OPTIONS = {
22
+ "default": ["native", "browser", "none"],
23
+ "terminal": ["native", "browser", "none"],
24
+ "vscode": ["vscode", "browser", "native", "none"],
25
+ "jupyter": ["inline", "browser", "native", "none"],
26
+ "ssh": ["browser", "none"],
27
+ "julia": ["browser", "native", "none"],
28
+ }
29
+ BUILTIN_PREFERENCES = {
30
+ "window": {
31
+ "default": "native",
32
+ "terminal": "native",
33
+ "vscode": "vscode",
34
+ "jupyter": "inline",
35
+ "ssh": "browser",
36
+ "julia": "browser",
37
+ },
38
+ "viewer": {
39
+ "theme": "dark",
40
+ "rounded_panes": True,
41
+ "ortho_layout": None,
42
+ "dimbar_mode": "compact",
43
+ },
44
+ }
45
+ PREFERENCE_SCHEMA = {
46
+ "window": WINDOW_PREFERENCE_OPTIONS,
47
+ "viewer": {
48
+ "theme": sorted(_VALID_THEMES),
49
+ "rounded_panes": "boolean",
50
+ "colormaps": "non-empty list of names",
51
+ "ortho_layout": sorted(_VALID_ORTHO_LAYOUTS),
52
+ "dimbar_mode": sorted(_VALID_DIMBAR_MODES),
53
+ },
54
+ "nninteractive": {"url": "string"},
55
+ }
56
+
57
+
58
+ class MalformedConfigError(ValueError):
59
+ """Raised when an update would overwrite an unreadable config file."""
60
+
61
+
62
+ class InvalidPreferenceError(ValueError):
63
+ """Raised when an update contains an unknown or invalid preference."""
64
+
65
+
66
+ def _load_config_strict() -> dict:
67
+ if not os.path.isfile(CONFIG_PATH):
68
+ return {}
69
+ try:
70
+ with open(CONFIG_PATH, encoding="utf-8") as f:
71
+ parsed = _parse_toml(f.read())
72
+ except Exception as exc:
73
+ raise MalformedConfigError("The existing config.toml is malformed") from exc
74
+ if not isinstance(parsed, dict):
75
+ raise MalformedConfigError("The existing config.toml is not a TOML table")
76
+ return parsed
77
+
78
+
79
+ def load_config() -> dict:
80
+ """Load config from TOML file. Returns {} on missing/malformed file."""
81
+ try:
82
+ return _load_config_strict()
83
+ except MalformedConfigError:
84
+ return {}
85
+
86
+
87
+ def save_config(config: dict) -> None:
88
+ """Atomically write a config dict to its TOML file."""
89
+ folder = os.path.dirname(CONFIG_PATH)
90
+ if folder:
91
+ os.makedirs(folder, exist_ok=True)
92
+ payload = _dump_toml(config)
93
+ fd, temp_path = tempfile.mkstemp(prefix=".config.", suffix=".tmp", dir=folder or ".")
94
+ try:
95
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
96
+ f.write(payload)
97
+ f.flush()
98
+ os.fsync(f.fileno())
99
+ os.replace(temp_path, CONFIG_PATH)
100
+ except Exception:
101
+ try:
102
+ os.unlink(temp_path)
103
+ except FileNotFoundError:
104
+ pass
105
+ raise
106
+
107
+
108
+ def _normalized_string(value, allowed: set[str], label: str) -> str:
109
+ if not isinstance(value, str) or value.strip().lower() not in allowed:
110
+ raise InvalidPreferenceError(f"{label} must be one of: {', '.join(sorted(allowed))}")
111
+ return value.strip().lower()
112
+
113
+
114
+ def _validate_preference(section: str, key: str, value):
115
+ if section == "window" and key in _VALID_ENV_KEYS:
116
+ return _normalized_string(
117
+ value, set(WINDOW_PREFERENCE_OPTIONS[key]), f"window.{key}"
118
+ )
119
+ if section == "viewer" and key == "theme":
120
+ return _normalized_string(value, _VALID_THEMES, "viewer.theme")
121
+ if section == "viewer" and key == "rounded_panes":
122
+ if not isinstance(value, bool):
123
+ raise InvalidPreferenceError("viewer.rounded_panes must be a boolean")
124
+ return value
125
+ if section == "viewer" and key == "colormaps":
126
+ if (
127
+ not isinstance(value, list)
128
+ or not value
129
+ or not all(isinstance(item, str) and item.strip() for item in value)
130
+ ):
131
+ raise InvalidPreferenceError("viewer.colormaps must be a non-empty list of names")
132
+ normalized = [item.strip() for item in value]
133
+ if len(set(normalized)) != len(normalized):
134
+ raise InvalidPreferenceError("viewer.colormaps must not contain duplicates")
135
+ return normalized
136
+ if section == "viewer" and key == "ortho_layout":
137
+ return _normalized_string(value, _VALID_ORTHO_LAYOUTS, "viewer.ortho_layout")
138
+ if section == "viewer" and key == "dimbar_mode":
139
+ return _normalized_string(value, _VALID_DIMBAR_MODES, "viewer.dimbar_mode")
140
+ if section == "nninteractive" and key == "url":
141
+ if not isinstance(value, str) or not value.strip():
142
+ raise InvalidPreferenceError("nninteractive.url must be a non-empty string")
143
+ return value.strip()
144
+ raise InvalidPreferenceError(f"Unknown preference: {section}.{key}")
145
+
146
+
147
+ def update_preferences(changes: dict) -> dict:
148
+ """Validate and merge preferences into the latest config on disk.
149
+
150
+ ``None`` removes a known preference, restoring its built-in default.
151
+ Unknown config sections and keys already on disk are preserved.
152
+ """
153
+ if not isinstance(changes, dict) or not changes:
154
+ raise InvalidPreferenceError("Preference changes must be a non-empty object")
155
+ validated: dict[str, dict] = {}
156
+ for section, values in changes.items():
157
+ if section not in PREFERENCE_SCHEMA or not isinstance(values, dict):
158
+ raise InvalidPreferenceError(f"Unknown preference section: {section}")
159
+ if not values:
160
+ raise InvalidPreferenceError(f"Preference section is empty: {section}")
161
+ validated[section] = {}
162
+ for key, value in values.items():
163
+ if key not in PREFERENCE_SCHEMA[section]:
164
+ raise InvalidPreferenceError(f"Unknown preference: {section}.{key}")
165
+ validated[section][key] = None if value is None else _validate_preference(section, key, value)
166
+
167
+ with _CONFIG_LOCK:
168
+ config = _load_config_strict()
169
+ merged = deepcopy(config)
170
+ for section, values in validated.items():
171
+ target = merged.get(section)
172
+ if target is None:
173
+ target = {}
174
+ merged[section] = target
175
+ if not isinstance(target, dict):
176
+ raise MalformedConfigError(f"Config section [{section}] is not a table")
177
+ for key, value in values.items():
178
+ if value is None:
179
+ target.pop(key, None)
180
+ else:
181
+ target[key] = value
182
+ if not target:
183
+ merged.pop(section, None)
184
+ save_config(merged)
185
+ return merged
186
+
187
+
188
+ def get_preferences() -> dict:
189
+ """Return only supported preferences currently present in the config."""
190
+ config = load_config()
191
+ result: dict[str, dict] = {}
192
+ for section, keys in PREFERENCE_SCHEMA.items():
193
+ source = config.get(section)
194
+ if not isinstance(source, dict):
195
+ continue
196
+ selected = {key: source[key] for key in keys if key in source}
197
+ if selected:
198
+ result[section] = selected
199
+ return result
200
+
201
+
202
+ def get_viewer_colormaps() -> list[str] | None:
203
+ """Return user-configured colormap cycle list, or None if not configured."""
204
+ viewer_cfg = load_config().get("viewer", {})
205
+ if not isinstance(viewer_cfg, dict):
206
+ return None
207
+ colormaps = viewer_cfg.get("colormaps")
208
+ if isinstance(colormaps, list) and all(isinstance(c, str) for c in colormaps) and colormaps:
209
+ return colormaps
210
+ return None
211
+
212
+
213
+ def get_viewer_theme() -> str | None:
214
+ """Return user-configured default theme name, or None if not configured."""
215
+ viewer_cfg = load_config().get("viewer", {})
216
+ if not isinstance(viewer_cfg, dict):
217
+ return None
218
+ theme = viewer_cfg.get("theme")
219
+ if isinstance(theme, str) and theme.strip().lower() in _VALID_THEMES:
220
+ return theme.strip().lower()
221
+ return None
222
+
223
+
224
+ def get_viewer_rounded_panes() -> bool | None:
225
+ """Return user-configured default for rounded panes, or None if not configured."""
226
+ viewer_cfg = load_config().get("viewer", {})
227
+ if not isinstance(viewer_cfg, dict):
228
+ return None
229
+ val = viewer_cfg.get("rounded_panes")
230
+ if isinstance(val, bool):
231
+ return val
232
+ if isinstance(val, str):
233
+ s = val.strip().lower()
234
+ if s in ("true", "1", "yes", "on"):
235
+ return True
236
+ if s in ("false", "0", "no", "off"):
237
+ return False
238
+ return None
239
+
240
+
241
+ def get_viewer_ortho_layout() -> str | None:
242
+ """Return the preferred initial ortho-view layout."""
243
+ viewer_cfg = load_config().get("viewer", {})
244
+ if not isinstance(viewer_cfg, dict):
245
+ return None
246
+ value = viewer_cfg.get("ortho_layout")
247
+ return value.strip().lower() if isinstance(value, str) and value.strip().lower() in _VALID_ORTHO_LAYOUTS else None
248
+
249
+
250
+ def get_viewer_dimbar_mode() -> str | None:
251
+ """Return the preferred initial dimbar mode."""
252
+ viewer_cfg = load_config().get("viewer", {})
253
+ if not isinstance(viewer_cfg, dict):
254
+ return None
255
+ value = viewer_cfg.get("dimbar_mode")
256
+ return value.strip().lower() if isinstance(value, str) and value.strip().lower() in _VALID_DIMBAR_MODES else None
257
+
258
+
259
+ def get_nninteractive_url() -> str | None:
260
+ """Return configured nnInteractive server URL, or None."""
261
+ env_val = os.environ.get("ARRAYVIEW_NNINTERACTIVE_URL", "").strip()
262
+ if env_val:
263
+ return env_val
264
+ nn_cfg = load_config().get("nninteractive", {})
265
+ if isinstance(nn_cfg, dict):
266
+ url = nn_cfg.get("url")
267
+ if isinstance(url, str) and url.strip():
268
+ return url.strip()
269
+ return None
270
+
271
+
272
+ def get_window_default(environment: str) -> str | None:
273
+ """Return the user's preferred window mode for the given environment."""
274
+ env_val = os.environ.get("ARRAYVIEW_WINDOW", "").strip().lower()
275
+ if env_val:
276
+ return env_val if env_val in _VALID_WINDOW_MODES else None
277
+ window_cfg = load_config().get("window", {})
278
+ if not isinstance(window_cfg, dict):
279
+ return None
280
+ val = window_cfg.get(environment) or window_cfg.get("default")
281
+ if isinstance(val, str) and val.strip().lower() in _VALID_WINDOW_MODES:
282
+ return val.strip().lower()
283
+ return None
284
+
285
+
286
+ def _parse_toml(text: str) -> dict:
287
+ import tomllib
288
+
289
+ return tomllib.loads(text)
290
+
291
+
292
+ def _toml_key(value: str) -> str:
293
+ if value and all(
294
+ char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
295
+ for char in value
296
+ ):
297
+ return value
298
+ return _toml_string(value)
299
+
300
+
301
+ def _toml_string(value: str) -> str:
302
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
303
+ escaped = escaped.replace("\b", "\\b").replace("\t", "\\t").replace("\n", "\\n")
304
+ escaped = escaped.replace("\f", "\\f").replace("\r", "\\r")
305
+ return f'"{escaped}"'
306
+
307
+
308
+ def _toml_value(value) -> str:
309
+ if isinstance(value, bool):
310
+ return "true" if value else "false"
311
+ if isinstance(value, str):
312
+ return _toml_string(value)
313
+ if isinstance(value, int):
314
+ return str(value)
315
+ if isinstance(value, float):
316
+ if not math.isfinite(value):
317
+ return str(value).lower()
318
+ return repr(value)
319
+ if isinstance(value, (_datetime.datetime, _datetime.date, _datetime.time)):
320
+ return value.isoformat()
321
+ if isinstance(value, list):
322
+ return "[" + ", ".join(_toml_value(item) for item in value) + "]"
323
+ raise TypeError(f"Unsupported TOML value type: {type(value).__name__}")
324
+
325
+
326
+ def _dump_toml(config: dict) -> str:
327
+ """Serialize nested dictionaries and scalar/list TOML values."""
328
+ lines: list[str] = []
329
+
330
+ def emit_table(path: tuple[str, ...], table: dict) -> None:
331
+ scalar_items = [(key, value) for key, value in table.items() if not isinstance(value, dict)]
332
+ child_items = [(key, value) for key, value in table.items() if isinstance(value, dict)]
333
+ if path:
334
+ lines.append("[" + ".".join(_toml_key(part) for part in path) + "]")
335
+ for key, value in scalar_items:
336
+ lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
337
+ if path and (scalar_items or not child_items):
338
+ lines.append("")
339
+ for key, value in child_items:
340
+ emit_table((*path, str(key)), value)
341
+
342
+ if not isinstance(config, dict):
343
+ raise TypeError("Config must be a dictionary")
344
+ root_scalars = {key: value for key, value in config.items() if not isinstance(value, dict)}
345
+ root_tables = {key: value for key, value in config.items() if isinstance(value, dict)}
346
+ for key, value in root_scalars.items():
347
+ lines.append(f"{_toml_key(str(key))} = {_toml_value(value)}")
348
+ if root_scalars and root_tables:
349
+ lines.append("")
350
+ for key, value in root_tables.items():
351
+ emit_table((str(key),), value)
352
+ return "\n".join(lines).rstrip() + ("\n" if lines else "")
@@ -5031,6 +5031,11 @@ def arrayview():
5031
5031
  action="store_true",
5032
5032
  help="Enable verbose output (internal status messages)",
5033
5033
  )
5034
+ parser.add_argument(
5035
+ "--trace",
5036
+ action="store_true",
5037
+ help="Show full tracebacks instead of user-friendly errors",
5038
+ )
5034
5039
  parser.add_argument(
5035
5040
  "--diagnose",
5036
5041
  action="store_true",
@@ -5695,24 +5700,55 @@ def arrayview():
5695
5700
  "[ArrayView] --window native is not supported on remote tunnel; using vscode instead."
5696
5701
  )
5697
5702
 
5698
- if execution_registration is Registration.HTTP_LOAD:
5699
- _handle_cli_existing_server(
5703
+ try:
5704
+ if execution_registration is Registration.HTTP_LOAD:
5705
+ _handle_cli_existing_server(
5706
+ port=args.port,
5707
+ base_file=base_file,
5708
+ name=name,
5709
+ compare_files=compare_files,
5710
+ overlay_files=[] if args.stack_mode else file_overlay_paths,
5711
+ overlay_names=[] if args.stack_mode else file_overlay_names,
5712
+ rgb=args.rgb,
5713
+ vectorfield=args.vectorfield,
5714
+ vfield_components_dim=vfield_components_dim,
5715
+ use_native_shell=use_native_shell,
5716
+ dims_override=dims_override,
5717
+ watch=getattr(args, "watch", False),
5718
+ window_mode=window_mode,
5719
+ floating=args.floating,
5720
+ is_remote=is_remote,
5721
+ launch_context=launch_context,
5722
+ dir_patterns=dir_patterns,
5723
+ dir_overlay_specs=dir_overlay_specs,
5724
+ dir_case_regex=args.case_regex,
5725
+ dir_exclude_cases=sorted(excluded_cases),
5726
+ collection_load=args.load,
5727
+ collection_stack=args.stack_policy or "auto",
5728
+ )
5729
+ return
5730
+
5731
+ demo_name = getattr(args, "_demo_name", None)
5732
+ demo_cleanup = getattr(args, "_demo_cleanup", False)
5733
+ _handle_cli_spawned_daemon(
5700
5734
  port=args.port,
5701
5735
  base_file=base_file,
5702
5736
  name=name,
5703
5737
  compare_files=compare_files,
5704
5738
  overlay_files=[] if args.stack_mode else file_overlay_paths,
5705
5739
  overlay_names=[] if args.stack_mode else file_overlay_names,
5706
- rgb=args.rgb,
5707
- vectorfield=args.vectorfield,
5708
- vfield_components_dim=vfield_components_dim,
5709
- use_native_shell=use_native_shell,
5710
5740
  dims_override=dims_override,
5741
+ use_native_shell=use_native_shell,
5711
5742
  watch=getattr(args, "watch", False),
5712
5743
  window_mode=window_mode,
5713
5744
  floating=args.floating,
5714
5745
  is_remote=is_remote,
5715
5746
  launch_context=launch_context,
5747
+ vectorfield=args.vectorfield,
5748
+ vfield_components_dim=vfield_components_dim,
5749
+ rgb=args.rgb,
5750
+ demo_name=demo_name,
5751
+ demo_cleanup=demo_cleanup,
5716
5752
  dir_patterns=dir_patterns,
5717
5753
  dir_overlay_specs=dir_overlay_specs,
5718
5754
  dir_case_regex=args.case_regex,
@@ -5720,33 +5756,9 @@ def arrayview():
5720
5756
  collection_load=args.load,
5721
5757
  collection_stack=args.stack_policy or "auto",
5722
5758
  )
5723
- return
5724
-
5725
- demo_name = getattr(args, "_demo_name", None)
5726
- demo_cleanup = getattr(args, "_demo_cleanup", False)
5727
- _handle_cli_spawned_daemon(
5728
- port=args.port,
5729
- base_file=base_file,
5730
- name=name,
5731
- compare_files=compare_files,
5732
- overlay_files=[] if args.stack_mode else file_overlay_paths,
5733
- overlay_names=[] if args.stack_mode else file_overlay_names,
5734
- dims_override=dims_override,
5735
- use_native_shell=use_native_shell,
5736
- watch=getattr(args, "watch", False),
5737
- window_mode=window_mode,
5738
- floating=args.floating,
5739
- is_remote=is_remote,
5740
- launch_context=launch_context,
5741
- vectorfield=args.vectorfield,
5742
- vfield_components_dim=vfield_components_dim,
5743
- rgb=args.rgb,
5744
- demo_name=demo_name,
5745
- demo_cleanup=demo_cleanup,
5746
- dir_patterns=dir_patterns,
5747
- dir_overlay_specs=dir_overlay_specs,
5748
- dir_case_regex=args.case_regex,
5749
- dir_exclude_cases=sorted(excluded_cases),
5750
- collection_load=args.load,
5751
- collection_stack=args.stack_policy or "auto",
5752
- )
5759
+ except Exception as e:
5760
+ if args.trace or os.environ.get("ARRAYVIEW_TRACE"):
5761
+ raise
5762
+ msg = str(e).removeprefix("[ArrayView] ").rstrip()
5763
+ print(f"\033[1;31m[ArrayView] {msg}\033[0m", file=sys.stderr)
5764
+ sys.exit(1)
@@ -0,0 +1,114 @@
1
+ """Routes for reading and updating persistent user preferences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ import os
7
+
8
+ from fastapi import Request
9
+ from fastapi.responses import JSONResponse
10
+
11
+ from arrayview._config import (
12
+ BUILTIN_PREFERENCES,
13
+ InvalidPreferenceError,
14
+ MalformedConfigError,
15
+ PREFERENCE_SCHEMA,
16
+ get_preferences,
17
+ update_preferences,
18
+ )
19
+ import arrayview._session as _session_mod
20
+ from arrayview._session import SESSIONS
21
+
22
+
23
+ def _response_payload() -> dict:
24
+ defaults = deepcopy(BUILTIN_PREFERENCES)
25
+ schema = deepcopy(PREFERENCE_SCHEMA)
26
+ for key, options in schema["window"].items():
27
+ schema["window"][key] = [value for value in options if value != "none"]
28
+ schema["window"].pop("default", None)
29
+ try:
30
+ from arrayview._platform import _is_vscode_remote, _native_window_gui
31
+
32
+ native_available = _native_window_gui() is not None
33
+ vscode_remote = _is_vscode_remote()
34
+ except Exception:
35
+ native_available = False
36
+ vscode_remote = False
37
+ if not native_available:
38
+ defaults["window"]["default"] = "browser"
39
+ defaults["window"]["terminal"] = "browser"
40
+ for key in ("default", "terminal", "vscode", "jupyter", "julia"):
41
+ schema["window"][key] = [
42
+ value for value in schema["window"][key] if value != "native"
43
+ ]
44
+ if vscode_remote:
45
+ schema["window"]["vscode"] = ["vscode", "none"]
46
+
47
+ overrides = {}
48
+ if os.environ.get("ARRAYVIEW_WINDOW", "").strip():
49
+ overrides["window"] = "ARRAYVIEW_WINDOW"
50
+ if os.environ.get("ARRAYVIEW_NNINTERACTIVE_URL", "").strip():
51
+ overrides["nninteractive.url"] = "ARRAYVIEW_NNINTERACTIVE_URL"
52
+ return {
53
+ "preferences": get_preferences(),
54
+ "defaults": defaults,
55
+ "schema": schema,
56
+ "overrides": overrides,
57
+ "server_instance_id": _session_mod.SERVER_RUNTIME.instance_id,
58
+ }
59
+
60
+
61
+ def register_preferences_routes(app) -> None:
62
+ @app.get("/preferences/{sid}")
63
+ async def preferences_get(sid: str):
64
+ if SESSIONS.get(sid) is None:
65
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
66
+ return _response_payload()
67
+
68
+ @app.patch("/preferences/{sid}")
69
+ async def preferences_patch(sid: str, request: Request):
70
+ if SESSIONS.get(sid) is None:
71
+ return JSONResponse({"error": "session_not_found"}, status_code=404)
72
+ content_type = request.headers.get("content-type", "").split(";", 1)[0].strip().lower()
73
+ if content_type != "application/json":
74
+ return JSONResponse({"error": "json_content_type_required"}, status_code=415)
75
+ if request.headers.get("x-arrayview-preferences") != "1":
76
+ return JSONResponse({"error": "preferences_header_required"}, status_code=403)
77
+ try:
78
+ body = await request.json()
79
+ except Exception:
80
+ return JSONResponse({"error": "invalid_json"}, status_code=400)
81
+ if not isinstance(body, dict):
82
+ return JSONResponse({"error": "invalid_json"}, status_code=400)
83
+ if body.get("server_instance_id") != _session_mod.SERVER_RUNTIME.instance_id:
84
+ return JSONResponse({"error": "stale_server_instance"}, status_code=409)
85
+ changes = body.get("changes")
86
+ try:
87
+ viewer_changes = changes.get("viewer", {}) if isinstance(changes, dict) else {}
88
+ colormaps = viewer_changes.get("colormaps")
89
+ if colormaps is not None:
90
+ from arrayview._render import _ensure_lut
91
+
92
+ invalid = [
93
+ name for name in colormaps
94
+ if not isinstance(name, str) or not _ensure_lut(name)
95
+ ]
96
+ if invalid:
97
+ raise InvalidPreferenceError(f"Unknown colormap: {invalid[0]}")
98
+ update_preferences(changes)
99
+ except InvalidPreferenceError as exc:
100
+ return JSONResponse(
101
+ {"error": "invalid_preference", "detail": str(exc)},
102
+ status_code=422,
103
+ )
104
+ except MalformedConfigError as exc:
105
+ return JSONResponse(
106
+ {"error": "malformed_config", "detail": str(exc)},
107
+ status_code=409,
108
+ )
109
+ except OSError as exc:
110
+ return JSONResponse(
111
+ {"error": "config_write_failed", "detail": str(exc)},
112
+ status_code=500,
113
+ )
114
+ return _response_payload()
@@ -41,6 +41,7 @@ from arrayview._routes_analysis import register_analysis_routes
41
41
  from arrayview._routes_loading import register_loading_routes
42
42
  from arrayview._routes_drop import register_drop_routes
43
43
  from arrayview._routes_persistence import register_persistence_routes
44
+ from arrayview._routes_preferences import register_preferences_routes
44
45
  from arrayview._routes_export import register_export_routes
45
46
  from arrayview._routes_preload import register_preload_routes
46
47
  from arrayview._routes_query import register_query_routes
@@ -49,7 +50,13 @@ from arrayview._routes_segmentation import register_segmentation_routes
49
50
  from arrayview._routes_state import register_state_routes
50
51
  from arrayview._routes_vectorfield import register_vectorfield_routes
51
52
  from arrayview._routes_websocket import _notify_shells, register_websocket_routes
52
- from arrayview._config import get_viewer_colormaps, get_viewer_rounded_panes, get_viewer_theme
53
+ from arrayview._config import (
54
+ get_viewer_colormaps,
55
+ get_viewer_dimbar_mode,
56
+ get_viewer_ortho_layout,
57
+ get_viewer_rounded_panes,
58
+ get_viewer_theme,
59
+ )
53
60
 
54
61
 
55
62
  from arrayview._imaging import ensure_image as _pil_image, ensure_imageops as _pil_imageops
@@ -176,6 +183,7 @@ register_websocket_routes(app)
176
183
  register_loading_routes(app, notify_shells=_notify_shells, setup_rgb=_setup_rgb)
177
184
  register_drop_routes(app)
178
185
  register_persistence_routes(app)
186
+ register_preferences_routes(app)
179
187
  register_segmentation_routes(app, get_session_or_404)
180
188
  register_state_routes(app, get_session_or_404)
181
189
  register_export_routes(app, get_session_or_404=get_session_or_404, pil_image=_pil_image)
@@ -287,12 +295,19 @@ def get_ui(sid: str = None):
287
295
  query_val = "null"
288
296
  _init_luts()
289
297
  _cfg_colormaps = get_viewer_colormaps()
290
- _active_colormaps = _cfg_colormaps if _cfg_colormaps is not None else COLORMAPS
298
+ _valid_cfg_colormaps = (
299
+ [name for name in _cfg_colormaps if _ensure_lut(name)]
300
+ if _cfg_colormaps is not None
301
+ else []
302
+ )
303
+ _active_colormaps = _valid_cfg_colormaps or COLORMAPS
291
304
  _theme_names = ["dark", "light"]
292
305
  _cfg_theme = get_viewer_theme()
293
306
  _default_theme_idx = _theme_names.index(_cfg_theme) if _cfg_theme in _theme_names else 0
294
307
  _cfg_rounded = get_viewer_rounded_panes()
295
308
  _default_rounded_panes = "false" if _cfg_rounded is False else "true"
309
+ _default_ortho_layout = json.dumps(get_viewer_ortho_layout())
310
+ _default_dimbar_mode = json.dumps(get_viewer_dimbar_mode())
296
311
  html = (
297
312
  _VIEWER_HTML_TEMPLATE.replace("__COLORMAPS__", str(_active_colormaps))
298
313
  .replace("__COLORMAP_GRADIENT_STOPS__", json.dumps(COLORMAP_GRADIENT_STOPS))
@@ -302,6 +317,8 @@ def get_ui(sid: str = None):
302
317
  .replace("__ARRAYVIEW_QUERY__", query_val)
303
318
  .replace("__DEFAULT_THEME_IDX__", str(_default_theme_idx))
304
319
  .replace("__DEFAULT_ROUNDED_PANES__", _default_rounded_panes)
320
+ .replace("__DEFAULT_ORTHO_LAYOUT__", _default_ortho_layout)
321
+ .replace("__DEFAULT_DIMBAR_MODE__", _default_dimbar_mode)
305
322
  .replace("__BODY_CLASS__", "av-loading" if sid else "")
306
323
  .replace("__ARRAYVIEW_VERSION__", _av_version)
307
324
  )