arrayview 0.38.1__tar.gz → 0.39.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {arrayview-0.38.1 → arrayview-0.39.0}/PKG-INFO +1 -1
- {arrayview-0.38.1 → arrayview-0.39.0}/pyproject.toml +1 -1
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_io.py +27 -3
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_launcher.py +20 -5
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_platform.py +19 -3
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_preferences.py +4 -1
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_query.py +1 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_viewer.html +101 -9
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_vscode_extension.py +6 -1
- arrayview-0.39.0/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/conftest.py +23 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_api.py +8 -3
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_launch_blackbox.py +3 -1
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_lifecycle_contract.py +9 -2
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_rgb_pixel_art.py +23 -7
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_view_launch_execution.py +5 -0
- arrayview-0.38.1/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/.gitignore +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/LICENSE +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/README.md +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/__init__.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/__main__.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_app.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_config.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_diff.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_icon.png +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_render.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_loading.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_routes_websocket.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_server.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_session.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_shell.html +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_torch.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/_vscode_signal.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/src/arrayview/todo.md +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/capture_v_animation.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/lifecycle_matrix.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_backend_shared.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_browser.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_cli.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_colorbar_hover_highlight.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_command_reachability.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_config.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_cross_mode_parametrized.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_dicom.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_display_opener_results.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_doctor_commands.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_drop_import.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_instance_registry.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_interactions.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_invocation_contract_matrix.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_io.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_large_arrays.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_launch_diagnostics.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_launch_execution_ownership.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_launch_plan.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_launch_trace.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_loading_server.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_mode_consistency.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_mode_matrix.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_nifti_series.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_preferences_ui.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_public_invocation_regressions.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_server_identity_status.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_session_spec.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_spawn_ownership.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_torch.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_value_formatting.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_view_component_integration.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_view_component_unit.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_view_handle_lifecycle.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/test_vscode_ack_protocol.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/ui_audit.py +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.38.1 → arrayview-0.39.0}/tests/visual_smoke.py +0 -0
|
@@ -1304,6 +1304,8 @@ def _load_file_series(path, *, load="lazy", stack="auto"):
|
|
|
1304
1304
|
Returns ``(series, spatial_meta)``.
|
|
1305
1305
|
"""
|
|
1306
1306
|
# ── collect files by parent directory ──────────────────────────
|
|
1307
|
+
from ._dicom import is_dicom_source
|
|
1308
|
+
|
|
1307
1309
|
patients: dict[str, list[str]] = {}
|
|
1308
1310
|
all_nifti = True
|
|
1309
1311
|
|
|
@@ -1312,6 +1314,14 @@ def _load_file_series(path, *, load="lazy", stack="auto"):
|
|
|
1312
1314
|
os.path.join(root, f)
|
|
1313
1315
|
for f in files
|
|
1314
1316
|
if _get_ext(f) in _SUPPORTED_EXTS
|
|
1317
|
+
# ".dcm" is necessary but not sufficient — every other .dcm
|
|
1318
|
+
# consumer (load_data, load_data_with_meta, _peek_file_shape's
|
|
1319
|
+
# directory branch) validates via is_dicom_source before
|
|
1320
|
+
# treating a file as real DICOM. Do the same here, otherwise a
|
|
1321
|
+
# stray file that merely has a .dcm extension gets counted as a
|
|
1322
|
+
# patient's series member and blows up load_data() downstream
|
|
1323
|
+
# instead of being silently ignored like any other unsupported file.
|
|
1324
|
+
if _get_ext(f) != ".dcm" or is_dicom_source(os.path.join(root, f))
|
|
1315
1325
|
)
|
|
1316
1326
|
if supported:
|
|
1317
1327
|
patients[root] = supported
|
|
@@ -1476,9 +1486,16 @@ def load_data_with_meta(filepath, key=None, *, load="lazy", stack="auto", select
|
|
|
1476
1486
|
"""
|
|
1477
1487
|
from ._dicom import is_dicom_source, load_dicom_series
|
|
1478
1488
|
|
|
1479
|
-
|
|
1489
|
+
# A ".zarr" store is itself a directory on disk. It must be excluded from
|
|
1490
|
+
# the generic "directory of files to stack" path below, or the walk finds
|
|
1491
|
+
# nothing matching a supported single-file extension inside it (zarr's own
|
|
1492
|
+
# chunk/metadata files don't) and raises instead of opening the store.
|
|
1493
|
+
is_zarr_dir = os.path.isdir(filepath) and _get_ext(filepath) == ".zarr"
|
|
1494
|
+
if (
|
|
1495
|
+
(os.path.isdir(filepath) and not is_zarr_dir) or filepath.lower().endswith(".dcm")
|
|
1496
|
+
) and is_dicom_source(filepath):
|
|
1480
1497
|
return load_dicom_series(filepath, select=select)
|
|
1481
|
-
if os.path.isdir(filepath):
|
|
1498
|
+
if os.path.isdir(filepath) and not is_zarr_dir:
|
|
1482
1499
|
return _load_file_series(filepath, load=load, stack=stack)
|
|
1483
1500
|
if filepath.endswith(".nii") or filepath.endswith(".nii.gz"):
|
|
1484
1501
|
return _load_nifti_with_meta(filepath)
|
|
@@ -1491,7 +1508,7 @@ def load_data(filepath, key=None):
|
|
|
1491
1508
|
|
|
1492
1509
|
data, _meta = load_dicom_series(filepath)
|
|
1493
1510
|
return data
|
|
1494
|
-
if os.path.isdir(filepath):
|
|
1511
|
+
if os.path.isdir(filepath) and _get_ext(filepath) != ".zarr":
|
|
1495
1512
|
from ._dicom import is_dicom_source, load_dicom_series
|
|
1496
1513
|
|
|
1497
1514
|
if is_dicom_source(filepath):
|
|
@@ -1646,6 +1663,13 @@ FULL_LOAD_EXTS = frozenset([".pt", ".pth", ".tif", ".tiff", ".mat"])
|
|
|
1646
1663
|
def _peek_file_shape(fpath: str, ext: str):
|
|
1647
1664
|
"""Try to return shape quickly without loading the full array. Returns None on failure."""
|
|
1648
1665
|
try:
|
|
1666
|
+
# ".zarr" stores are directories, but they're a single array, not a
|
|
1667
|
+
# collection to walk and stack — read metadata via zarr, not the
|
|
1668
|
+
# directory-of-files branch below.
|
|
1669
|
+
if ext in (".zarr", ".zarr.zip"):
|
|
1670
|
+
import zarr
|
|
1671
|
+
|
|
1672
|
+
return list(zarr.open(fpath, mode="r").shape)
|
|
1649
1673
|
if os.path.isdir(fpath):
|
|
1650
1674
|
from ._dicom import is_dicom_source, load_dicom_series
|
|
1651
1675
|
|
|
@@ -44,6 +44,21 @@ import arrayview._platform as _platform_mod # for mutable globals
|
|
|
44
44
|
_vscode_mod_cache = None
|
|
45
45
|
|
|
46
46
|
|
|
47
|
+
def _detached_child_kwargs() -> dict[str, object]:
|
|
48
|
+
"""Popen options placing a spawned child outside our own signal reach.
|
|
49
|
+
|
|
50
|
+
``start_new_session`` is POSIX-only, so passing ``sys.platform != "win32"``
|
|
51
|
+
left every Windows spawn in the parent's console process group. A console
|
|
52
|
+
control event raised for that group reaches *every* process attached to it,
|
|
53
|
+
so a child being shut down can deliver a KeyboardInterrupt to the process
|
|
54
|
+
that spawned it — under pytest, to the test runner itself. Both branches
|
|
55
|
+
state the same intent: the child gets its own group.
|
|
56
|
+
"""
|
|
57
|
+
if sys.platform == "win32":
|
|
58
|
+
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
59
|
+
return {"start_new_session": True}
|
|
60
|
+
|
|
61
|
+
|
|
47
62
|
def _trace_launch_event(event: str, **attrs: object) -> None:
|
|
48
63
|
"""Emit an opt-in launch event without loading tracing on normal paths."""
|
|
49
64
|
if not os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
|
|
@@ -495,7 +510,7 @@ def _open_webview(
|
|
|
495
510
|
stdout=subprocess.DEVNULL,
|
|
496
511
|
stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL,
|
|
497
512
|
close_fds=True,
|
|
498
|
-
|
|
513
|
+
**_detached_child_kwargs(),
|
|
499
514
|
)
|
|
500
515
|
|
|
501
516
|
# URL mode — direct load (used when shell_port not provided)
|
|
@@ -545,7 +560,7 @@ def _open_webview(
|
|
|
545
560
|
stdout=subprocess.DEVNULL,
|
|
546
561
|
stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL,
|
|
547
562
|
close_fds=True,
|
|
548
|
-
|
|
563
|
+
**_detached_child_kwargs(),
|
|
549
564
|
)
|
|
550
565
|
|
|
551
566
|
|
|
@@ -2062,7 +2077,7 @@ def _handle_cli_spawned_daemon(
|
|
|
2062
2077
|
"stdout": subprocess.DEVNULL,
|
|
2063
2078
|
"stderr": subprocess.DEVNULL,
|
|
2064
2079
|
"close_fds": True,
|
|
2065
|
-
|
|
2080
|
+
**_detached_child_kwargs(),
|
|
2066
2081
|
}
|
|
2067
2082
|
if os.environ.get("ARRAYVIEW_LAUNCH_TRACE"):
|
|
2068
2083
|
from arrayview._launch_trace import trace_child_environment
|
|
@@ -3998,7 +4013,7 @@ def _view_subprocess(
|
|
|
3998
4013
|
stdout=subprocess.DEVNULL,
|
|
3999
4014
|
stderr=subprocess.DEVNULL,
|
|
4000
4015
|
close_fds=True,
|
|
4001
|
-
|
|
4016
|
+
**_detached_child_kwargs(),
|
|
4002
4017
|
)
|
|
4003
4018
|
if not _wait_for_spawned_server(daemon_proc, port, timeout=15.0):
|
|
4004
4019
|
raise RuntimeError(
|
|
@@ -5439,7 +5454,7 @@ def arrayview():
|
|
|
5439
5454
|
stdout=subprocess.DEVNULL,
|
|
5440
5455
|
stderr=subprocess.DEVNULL,
|
|
5441
5456
|
close_fds=True,
|
|
5442
|
-
|
|
5457
|
+
**_detached_child_kwargs(),
|
|
5443
5458
|
)
|
|
5444
5459
|
if not _wait_for_spawned_server(proc, args.port, timeout=15.0):
|
|
5445
5460
|
_terminate_owned_process(proc)
|
|
@@ -581,9 +581,25 @@ def _exact_vscode_registration_remote(ipc: str) -> bool | None:
|
|
|
581
581
|
both can exist in a local desktop window.
|
|
582
582
|
"""
|
|
583
583
|
exact = _exact_vscode_window_registration(ipc)
|
|
584
|
-
if exact is
|
|
585
|
-
return
|
|
586
|
-
|
|
584
|
+
if exact is None or "remoteName" not in exact[1]:
|
|
585
|
+
return None
|
|
586
|
+
# _exact_vscode_window_registration's ARRAYVIEW_WINDOW_ID fallback trusts
|
|
587
|
+
# that id's own file directly and unconditionally — other callers (signal
|
|
588
|
+
# targeting, extension-version checks) need it to still resolve a window
|
|
589
|
+
# when ipc is unavailable or has legitimately drifted (e.g. a tunnel
|
|
590
|
+
# re-sockets without the window itself changing). Answering "is this
|
|
591
|
+
# remote" is riskier: a wrong placement picks the wrong display path
|
|
592
|
+
# outright. So here specifically, a raw env-var match with nothing to
|
|
593
|
+
# corroborate it against a live, unmatched ipc is refused rather than
|
|
594
|
+
# trusted — fall through to the caller's other signals instead.
|
|
595
|
+
env_window_id = os.environ.get("ARRAYVIEW_WINDOW_ID")
|
|
596
|
+
if (
|
|
597
|
+
ipc
|
|
598
|
+
and exact[0] == env_window_id
|
|
599
|
+
and exact[0] != hashlib.sha256(ipc.encode()).hexdigest()[:16]
|
|
600
|
+
):
|
|
601
|
+
return None
|
|
602
|
+
return exact[1].get("remoteName") is not None
|
|
587
603
|
|
|
588
604
|
|
|
589
605
|
def _has_live_vscode_remote_registration() -> bool:
|
|
@@ -42,7 +42,10 @@ def _response_payload() -> dict:
|
|
|
42
42
|
value for value in schema["window"][key] if value != "native"
|
|
43
43
|
]
|
|
44
44
|
if vscode_remote:
|
|
45
|
-
|
|
45
|
+
# Native and browser don't make sense from a headless remote host;
|
|
46
|
+
# "none" is an internal fallback only, hidden from every row above —
|
|
47
|
+
# this hardcoded replacement must keep hiding it too.
|
|
48
|
+
schema["window"]["vscode"] = ["vscode"]
|
|
46
49
|
|
|
47
50
|
overrides = {}
|
|
48
51
|
if os.environ.get("ARRAYVIEW_WINDOW", "").strip():
|
|
@@ -6159,6 +6159,8 @@
|
|
|
6159
6159
|
let rangeLocked = true; // when true, d-set limits never auto-recompute on navigation
|
|
6160
6160
|
let vminLocked = false; // lock lower bound independently
|
|
6161
6161
|
let vmaxLocked = false; // lock upper bound independently
|
|
6162
|
+
let symmetricLocked = false; // tie vmin = -vmax so zero stays centred
|
|
6163
|
+
let _dmenuSymmetricMirroring = false; // re-entrancy guard for the mirror write
|
|
6162
6164
|
let excludeZeros = false; // exclude zero-valued voxels from histogram computation
|
|
6163
6165
|
let spatialMeta = null; // {voxel_sizes, axis_labels, is_oblique} or null
|
|
6164
6166
|
let rasResampleActive = false;
|
|
@@ -20118,6 +20120,8 @@
|
|
|
20118
20120
|
|
|
20119
20121
|
// ── Brightness/Contrast drag (Cmd+Shift+drag) and scroll ─────────
|
|
20120
20122
|
let _bcDrag = null; // { startX, startY, initCenter, initWidth }
|
|
20123
|
+
let _bcPendingDrag = null; // { startX, startY } — armed plain-left press, not yet a drag
|
|
20124
|
+
const _BC_DRAG_DEADZONE = 5; // CSS px a press must travel before it counts as windowing
|
|
20121
20125
|
let _bcScrollTimer = null; // debounce timer for scroll-based BC
|
|
20122
20126
|
let _bcScrollAnchor = null; // { center, width, accumX, accumY } for scroll accumulation
|
|
20123
20127
|
let _bcUpdateTimer = null; // throttle timer for server updates during drag
|
|
@@ -20183,6 +20187,12 @@
|
|
|
20183
20187
|
startX: e.clientX, startY: e.clientY,
|
|
20184
20188
|
initCenter: (vmin + vmax) / 2,
|
|
20185
20189
|
initWidth: vmax - vmin,
|
|
20190
|
+
// A locked vmin or a symmetric lock removes a degree of
|
|
20191
|
+
// freedom, so the drag has to collapse to one axis — see
|
|
20192
|
+
// _bcUpdateDrag.
|
|
20193
|
+
vminLocked: _dmenuEffectiveVminLocked(),
|
|
20194
|
+
symmetric: _dmenuEffectiveSymmetricLocked(),
|
|
20195
|
+
lockedLo: vmin,
|
|
20186
20196
|
};
|
|
20187
20197
|
canvas.style.cursor = 'crosshair';
|
|
20188
20198
|
_bcExpandHist();
|
|
@@ -20193,12 +20203,46 @@
|
|
|
20193
20203
|
const dy = e.clientY - _bcDrag.startY;
|
|
20194
20204
|
// Sensitivity: scale by initial width so drag feels proportional
|
|
20195
20205
|
const sens = _bcDrag.initWidth / 300;
|
|
20196
|
-
|
|
20197
|
-
|
|
20206
|
+
let newWidth = _bcDrag.initWidth - dy * sens * 2;
|
|
20207
|
+
let newCenter;
|
|
20208
|
+
if (_bcDrag.symmetric) {
|
|
20209
|
+
// Bounds are tied to ±half-width, so the centre is fixed at
|
|
20210
|
+
// zero and only the width is free — vertical-only, same shape
|
|
20211
|
+
// as the pinned-vmin case below but anchored differently.
|
|
20212
|
+
newWidth = Math.max(newWidth, Math.abs(_bcDrag.initWidth) * 1e-3 || 1e-9);
|
|
20213
|
+
newCenter = 0;
|
|
20214
|
+
} else if (_bcDrag.vminLocked) {
|
|
20215
|
+
// vmin is pinned (manual lock, qMRI role, or zero floor), so
|
|
20216
|
+
// vmax is the only free bound and centre/width are no longer
|
|
20217
|
+
// independent. The horizontal axis has nothing to move: the
|
|
20218
|
+
// drag collapses to vertical-only, growing the window upward
|
|
20219
|
+
// from the pinned vmin. Clamped so vmax can never cross it.
|
|
20220
|
+
newWidth = Math.max(newWidth, Math.abs(_bcDrag.initWidth) * 1e-3 || 1e-9);
|
|
20221
|
+
newCenter = _bcDrag.lockedLo + newWidth / 2;
|
|
20222
|
+
} else {
|
|
20223
|
+
newCenter = _bcDrag.initCenter - dx * sens;
|
|
20224
|
+
}
|
|
20198
20225
|
_bcApplyWindow(newCenter, newWidth);
|
|
20199
20226
|
_bcThrottledUpdate();
|
|
20200
20227
|
}
|
|
20228
|
+
// A plain left press arms a pending drag rather than starting one, so a
|
|
20229
|
+
// press that never travels stays a click-to-copy / pixel-info hold.
|
|
20230
|
+
// Past the deadzone it becomes window/level — the PACS convention for a
|
|
20231
|
+
// single 2D view. Ortho and the other multi-view panes have their own
|
|
20232
|
+
// canvases and keep left-drag for crosshair navigation.
|
|
20233
|
+
function _bcMaybePromotePending(e) {
|
|
20234
|
+
if (!_bcPendingDrag || _bcDrag) return;
|
|
20235
|
+
if (!(e.buttons & 1)) { _bcPendingDrag = null; return; }
|
|
20236
|
+
const origin = _bcPendingDrag;
|
|
20237
|
+
if (Math.hypot(e.clientX - origin.startX, e.clientY - origin.startY) < _BC_DRAG_DEADZONE) return;
|
|
20238
|
+
_bcPendingDrag = null;
|
|
20239
|
+
if (_pixelInfoHeld) { _pixelInfoHeld = false; hideAllPixelInfo(); }
|
|
20240
|
+
// Anchor to the press origin, not to here, so the window doesn't
|
|
20241
|
+
// jump by the deadzone distance on the first frame.
|
|
20242
|
+
_bcStartDrag({ clientX: origin.startX, clientY: origin.startY });
|
|
20243
|
+
}
|
|
20201
20244
|
function _bcEndDrag() {
|
|
20245
|
+
_bcPendingDrag = null;
|
|
20202
20246
|
if (!_bcDrag) return;
|
|
20203
20247
|
_bcDrag = null;
|
|
20204
20248
|
canvas.style.cursor = '';
|
|
@@ -20321,6 +20365,7 @@
|
|
|
20321
20365
|
if (!rectRoiMode && e.button === 0 && !e.shiftKey && !e.metaKey && !e.altKey
|
|
20322
20366
|
&& !_cropActive && !_segMode && !_rulerMode) {
|
|
20323
20367
|
_pixelInfoHeld = true;
|
|
20368
|
+
if (_bcCanDrag()) _bcPendingDrag = { startX: e.clientX, startY: e.clientY };
|
|
20324
20369
|
canvas.dispatchEvent(new MouseEvent('mousemove', { clientX: e.clientX, clientY: e.clientY, buttons: 1, ctrlKey: e.ctrlKey, bubbles: true }));
|
|
20325
20370
|
setTimeout(() => {
|
|
20326
20371
|
if (!_pixelInfoHeld) return;
|
|
@@ -21437,7 +21482,7 @@
|
|
|
21437
21482
|
}
|
|
21438
21483
|
// Old export-btn and roi-content listeners removed — now handled by island actions
|
|
21439
21484
|
// Brightness/contrast: mousemove updates vmin/vmax
|
|
21440
|
-
canvas.addEventListener('mousemove', e => { _bcUpdateDrag(e); });
|
|
21485
|
+
canvas.addEventListener('mousemove', e => { _bcMaybePromotePending(e); _bcUpdateDrag(e); });
|
|
21441
21486
|
// Crop drag-handle: while dragging, update the matching edge; otherwise
|
|
21442
21487
|
// provide hover-cursor feedback when near an edge. Runs even when
|
|
21443
21488
|
// Alt is held so we still update during an accidental Alt hover,
|
|
@@ -34766,6 +34811,17 @@
|
|
|
34766
34811
|
return vminLocked || _dmenuPolicyLocksVmin();
|
|
34767
34812
|
}
|
|
34768
34813
|
|
|
34814
|
+
// Signed data whose zero is a reference point — difference images,
|
|
34815
|
+
// phase, field maps, statistical maps — only reads correctly when zero
|
|
34816
|
+
// sits at the centre of the colormap; window it asymmetrically and the
|
|
34817
|
+
// neutral colour drifts off zero, so sign can no longer be judged by
|
|
34818
|
+
// eye. This lock ties the bounds together to prevent that. It stays
|
|
34819
|
+
// opt-in rather than auto-engaging on any signed data, because plenty
|
|
34820
|
+
// of signed data (CT in Hounsfield units) has no symmetry about zero.
|
|
34821
|
+
function _dmenuEffectiveSymmetricLocked() {
|
|
34822
|
+
return symmetricLocked;
|
|
34823
|
+
}
|
|
34824
|
+
|
|
34769
34825
|
function _dmenuRows() {
|
|
34770
34826
|
const qmriPolicyLocksVmin = _dmenuQmriPolicyLocksVmin();
|
|
34771
34827
|
const zeroFloorLocksVmin = _dmenuZeroFloorLocksVmin();
|
|
@@ -34773,6 +34829,8 @@
|
|
|
34773
34829
|
return [
|
|
34774
34830
|
{ kind: 'lock', action: 'vmin', label: 'vmin', policyLocked: policyLocksVmin, zeroFloorLocked: zeroFloorLocksVmin, qmriPolicyLocked: qmriPolicyLocksVmin, note: _dmenuEffectiveVminLocked() ? 'locked' : '' },
|
|
34775
34831
|
{ kind: 'lock', action: 'vmax', label: 'vmax', note: vmaxLocked ? 'locked' : '' },
|
|
34832
|
+
{ kind: 'lock', action: 'symmetric', label: '±', note: _dmenuEffectiveSymmetricLocked() ? 'locked' : '',
|
|
34833
|
+
lockTitle: _dmenuEffectiveSymmetricLocked() ? 'symmetric: zero centred' : 'symmetric: unlocked' },
|
|
34776
34834
|
];
|
|
34777
34835
|
}
|
|
34778
34836
|
|
|
@@ -34799,11 +34857,13 @@
|
|
|
34799
34857
|
btn.setAttribute('aria-checked', locked ? 'true' : 'false');
|
|
34800
34858
|
if (idx === _dmenuSelectedIdx) btn.classList.add('is-selected');
|
|
34801
34859
|
if (locked) btn.classList.add('is-locked');
|
|
34802
|
-
const lockLabel = row.
|
|
34803
|
-
?
|
|
34804
|
-
: row.
|
|
34805
|
-
? `${row.label}: locked at 0`
|
|
34806
|
-
:
|
|
34860
|
+
const lockLabel = row.lockTitle
|
|
34861
|
+
? row.lockTitle
|
|
34862
|
+
: row.qmriPolicyLocked
|
|
34863
|
+
? `${row.label}: locked at 0 by qMRI role`
|
|
34864
|
+
: row.zeroFloorLocked
|
|
34865
|
+
? `${row.label}: locked at 0`
|
|
34866
|
+
: (locked ? `${row.label}: locked` : `${row.label}: unlocked`);
|
|
34807
34867
|
btn.title = lockLabel;
|
|
34808
34868
|
btn.setAttribute('aria-label', lockLabel);
|
|
34809
34869
|
btn.innerHTML = locked
|
|
@@ -34825,7 +34885,11 @@
|
|
|
34825
34885
|
});
|
|
34826
34886
|
return btn;
|
|
34827
34887
|
};
|
|
34828
|
-
return [
|
|
34888
|
+
return [
|
|
34889
|
+
makeLock(rows[0], 0, _dmenuEffectiveVminLocked()),
|
|
34890
|
+
makeLock(rows[1], 1, vmaxLocked),
|
|
34891
|
+
makeLock(rows[2], 2, _dmenuEffectiveSymmetricLocked()),
|
|
34892
|
+
];
|
|
34829
34893
|
}
|
|
34830
34894
|
|
|
34831
34895
|
function _dmenuSetBoundManual(action, value) {
|
|
@@ -34865,6 +34929,14 @@
|
|
|
34865
34929
|
if (compareMvActive) compareMvViews.forEach(v => { v.lockedVmax = manualVmax; });
|
|
34866
34930
|
if (compareQmriActive) compareQmriViews.forEach(v => { v.lockedVmax = manualVmax; });
|
|
34867
34931
|
}
|
|
34932
|
+
// Under a symmetric lock the bounds move together, so mirror the
|
|
34933
|
+
// write through the same per-mode fan-out above rather than
|
|
34934
|
+
// duplicating it. The guard stops the mirror from recursing.
|
|
34935
|
+
if (!_dmenuSymmetricMirroring && _dmenuEffectiveSymmetricLocked()) {
|
|
34936
|
+
_dmenuSymmetricMirroring = true;
|
|
34937
|
+
try { _dmenuSetBoundManual(action === 'vmin' ? 'vmax' : 'vmin', -value); }
|
|
34938
|
+
finally { _dmenuSymmetricMirroring = false; }
|
|
34939
|
+
}
|
|
34868
34940
|
}
|
|
34869
34941
|
|
|
34870
34942
|
function _dmenuClearPresetForManualRange(target) {
|
|
@@ -35351,6 +35423,26 @@
|
|
|
35351
35423
|
if (_dmenuApplyBoundPreset(row.action, row.index)) _dmenuCloseAfterSelection();
|
|
35352
35424
|
return;
|
|
35353
35425
|
}
|
|
35426
|
+
if (row.action === 'symmetric') {
|
|
35427
|
+
symmetricLocked = !symmetricLocked;
|
|
35428
|
+
if (symmetricLocked) {
|
|
35429
|
+
// Snap the current window symmetric straight away so the
|
|
35430
|
+
// toggle has a visible effect rather than only shaping
|
|
35431
|
+
// later adjustments. The widest bound sets the half-width
|
|
35432
|
+
// so nothing currently visible gets clipped away.
|
|
35433
|
+
const half = Math.max(
|
|
35434
|
+
Math.abs(Number(manualVmin ?? currentVmin)),
|
|
35435
|
+
Math.abs(Number(manualVmax ?? currentVmax)),
|
|
35436
|
+
);
|
|
35437
|
+
if (Number.isFinite(half) && half > 0) _dmenuSetBoundManual('vmax', half);
|
|
35438
|
+
}
|
|
35439
|
+
_dmenuRefreshAfterRangeChange();
|
|
35440
|
+
_dmenuPickerRender();
|
|
35441
|
+
_restartHistAutoDismiss();
|
|
35442
|
+
saveState();
|
|
35443
|
+
if (closeAfterSelection) _dmenuCloseAfterSelection();
|
|
35444
|
+
return;
|
|
35445
|
+
}
|
|
35354
35446
|
if (row.action === 'vmin') {
|
|
35355
35447
|
if (row.zeroFloorLocked) {
|
|
35356
35448
|
_dmenuZeroFloorVminUnlocked = true;
|
|
@@ -55,7 +55,7 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
|
|
|
55
55
|
_VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
|
|
56
56
|
_VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
|
|
57
57
|
_VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
|
|
58
|
-
_VSCODE_EXT_VERSION = "0.14.
|
|
58
|
+
_VSCODE_EXT_VERSION = "0.14.90" # current bundled extension version
|
|
59
59
|
_VSCODE_CONFIGURED_PORTS: set[int] = set()
|
|
60
60
|
|
|
61
61
|
def _bundled_vscode_vsix_version(vsix_path: str) -> str | None:
|
|
@@ -358,7 +358,12 @@ def _run_extension_installer(command: list[str], env: dict[str, str]) -> subproc
|
|
|
358
358
|
stderr=subprocess.PIPE,
|
|
359
359
|
text=True,
|
|
360
360
|
env=env,
|
|
361
|
+
# Same intent on both platforms: the installer gets its own group, so
|
|
362
|
+
# neither its shutdown nor a console Ctrl-C crosses between us and it.
|
|
361
363
|
start_new_session=os.name != "nt",
|
|
364
|
+
creationflags=(
|
|
365
|
+
subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0
|
|
366
|
+
),
|
|
362
367
|
)
|
|
363
368
|
try:
|
|
364
369
|
stdout, stderr = process.communicate(timeout=30)
|
|
Binary file
|
|
@@ -11,6 +11,29 @@ import uvicorn
|
|
|
11
11
|
from arrayview._app import app
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Isolation
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
@pytest.fixture(autouse=True)
|
|
19
|
+
def isolated_runtime_directory(tmp_path_factory, monkeypatch):
|
|
20
|
+
"""Give every test its own instance registry.
|
|
21
|
+
|
|
22
|
+
An in-process ``view()`` registers the *running interpreter* as an ArrayView
|
|
23
|
+
instance. Sharing one registry therefore lets a later test that shells out
|
|
24
|
+
to ``arrayview stop`` discover the test runner, verify it as a legitimately
|
|
25
|
+
owned instance, and SIGTERM it — pytest dies mid-file with exit 143 and no
|
|
26
|
+
failing test to point at. Subprocesses inherit the variable, so the whole
|
|
27
|
+
tree stays inside the sandbox.
|
|
28
|
+
|
|
29
|
+
It also keeps a local test run from reaching the developer's real registry
|
|
30
|
+
and stopping viewers they have open.
|
|
31
|
+
"""
|
|
32
|
+
monkeypatch.setenv(
|
|
33
|
+
"ARRAYVIEW_RUNTIME_DIR", str(tmp_path_factory.mktemp("runtime"))
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
14
37
|
# ---------------------------------------------------------------------------
|
|
15
38
|
# Server
|
|
16
39
|
# ---------------------------------------------------------------------------
|
|
@@ -3447,9 +3447,14 @@ class TestCliOpenHelpers:
|
|
|
3447
3447
|
assert "kw = {'gui': gui} if gui else {}" in command[2]
|
|
3448
3448
|
assert kwargs["stdin"] is subprocess.DEVNULL
|
|
3449
3449
|
assert kwargs["close_fds"] is True
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3450
|
+
# The child must land in its own group on every platform; the option
|
|
3451
|
+
# that achieves it differs, and Windows has no start_new_session.
|
|
3452
|
+
if launcher.sys.platform == "win32":
|
|
3453
|
+
assert (
|
|
3454
|
+
kwargs["creationflags"] & subprocess.CREATE_NEW_PROCESS_GROUP
|
|
3455
|
+
)
|
|
3456
|
+
else:
|
|
3457
|
+
assert kwargs["start_new_session"] is True
|
|
3453
3458
|
|
|
3454
3459
|
def test_build_inline_shell_html_preserves_init_query(self):
|
|
3455
3460
|
import arrayview._launcher as launcher
|
|
@@ -505,8 +505,10 @@ def test_cli_vscode_protocol_process_session_display_and_cleanup(browser, tmp_pa
|
|
|
505
505
|
)
|
|
506
506
|
assert httpx.get(f"http://localhost:{port}/ping", timeout=1).status_code == 200
|
|
507
507
|
|
|
508
|
+
# `stop` takes no target: it stops every instance in the registry, and
|
|
509
|
+
# this run owns a private ARRAYVIEW_RUNTIME_DIR holding just this daemon.
|
|
508
510
|
stop = subprocess.run(
|
|
509
|
-
[str(console), "stop"
|
|
511
|
+
[str(console), "stop"],
|
|
510
512
|
cwd=tmp_path,
|
|
511
513
|
env=env,
|
|
512
514
|
capture_output=True,
|
|
@@ -1692,9 +1692,16 @@ def test_vscode_open_ack_requires_requested_session_metadata():
|
|
|
1692
1692
|
source = (Path(__file__).resolve().parents[1] / "vscode-extension" / "extension.js").read_text()
|
|
1693
1693
|
|
|
1694
1694
|
assert "sessionMetadataUrlFromViewerUrl(openUrl)" in source
|
|
1695
|
-
assert "sessionMetadataUrlFromViewerUrl(url)" in source
|
|
1696
1695
|
assert "expired before a panel could be opened" in source
|
|
1697
|
-
|
|
1696
|
+
# The session wait deliberately moved after panel creation, so the viewer can
|
|
1697
|
+
# show its own loading state while a large array is still being read. What
|
|
1698
|
+
# must not move: the ACK may not claim readiness until the requested session
|
|
1699
|
+
# actually answers, and a failed load surfaces its reason instead of hanging.
|
|
1700
|
+
assert "await waitForSessionReady(metadataUrl, metadataWaitMs)" in source
|
|
1701
|
+
assert "sessionState.loadError" in source
|
|
1702
|
+
assert source.index("waitForSessionReady(metadataUrl") < source.index(
|
|
1703
|
+
"advanceAck('backend_ready')"
|
|
1704
|
+
)
|
|
1698
1705
|
assert "waitForViewerReady(panel, viewerTimeoutMs)" in source
|
|
1699
1706
|
assert "message.phase === 'frame-rendered'" in source
|
|
1700
1707
|
assert "await viewerReady" in source
|
|
@@ -1,7 +1,17 @@
|
|
|
1
|
+
"""Animate a real photo into a 5D (day/night x wind x H x W x RGB) array and
|
|
2
|
+
view it. Exploratory/manual — needs a real image dropped in next to this file,
|
|
3
|
+
which we don't commit (large binary), so it skips cleanly everywhere else.
|
|
4
|
+
"""
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
1
7
|
import numpy as np
|
|
8
|
+
import pytest
|
|
2
9
|
from PIL import Image
|
|
10
|
+
|
|
3
11
|
import arrayview as av
|
|
4
12
|
|
|
13
|
+
IMAGE_PATH = Path(__file__).parent / "vangogh.jpg"
|
|
14
|
+
|
|
5
15
|
|
|
6
16
|
def generate_animated_landscape_array(
|
|
7
17
|
image_path, num_dn_steps=20, num_wind_steps=20, target_size=None
|
|
@@ -56,11 +66,17 @@ def generate_animated_landscape_array(
|
|
|
56
66
|
return animated_array
|
|
57
67
|
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
)
|
|
64
|
-
|
|
69
|
+
@pytest.fixture
|
|
70
|
+
def animated_landscape():
|
|
71
|
+
if not IMAGE_PATH.exists():
|
|
72
|
+
pytest.skip(f"{IMAGE_PATH.name} not present (not committed to the repo)")
|
|
73
|
+
return generate_animated_landscape_array(str(IMAGE_PATH), target_size=(768, 512))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_animated_landscape_array_shape(animated_landscape):
|
|
77
|
+
assert animated_landscape.shape == (20, 20, 512, 768, 3)
|
|
78
|
+
assert animated_landscape.dtype == np.uint8
|
|
79
|
+
|
|
65
80
|
|
|
66
|
-
|
|
81
|
+
def test_animated_landscape_viewable(animated_landscape):
|
|
82
|
+
av.view(animated_landscape, rgb=True)
|
|
@@ -233,6 +233,7 @@ def test_existing_server_executes_python_plan(
|
|
|
233
233
|
)
|
|
234
234
|
def test_in_process_server_executes_python_plan(
|
|
235
235
|
monkeypatch,
|
|
236
|
+
request,
|
|
236
237
|
display,
|
|
237
238
|
requested_window,
|
|
238
239
|
expected_force_vscode,
|
|
@@ -256,6 +257,10 @@ def test_in_process_server_executes_python_plan(
|
|
|
256
257
|
monkeypatch.setattr(session_mod, "SERVER_LOOP", None)
|
|
257
258
|
|
|
258
259
|
handle = launcher.view(np.zeros((2, 2)), window=requested_window)
|
|
260
|
+
# An in-process server registers this interpreter as a live ArrayView
|
|
261
|
+
# instance. Left open, it stays in the registry for the rest of the session
|
|
262
|
+
# and any later `arrayview stop` would act on the test runner itself.
|
|
263
|
+
request.addfinalizer(handle.close)
|
|
259
264
|
|
|
260
265
|
assert isinstance(handle, launcher.ViewHandle)
|
|
261
266
|
assert len(opened) == expected_opens
|
|
Binary file
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|