arrayview 0.49.1__tar.gz → 0.50.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.
- {arrayview-0.49.1 → arrayview-0.50.1}/PKG-INFO +1 -1
- {arrayview-0.49.1 → arrayview-0.50.1}/pyproject.toml +1 -1
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_loading.py +20 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_websocket.py +72 -11
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_viewer.html +302 -31
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode_extension.py +1 -1
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode_signal.py +6 -2
- arrayview-0.50.1/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_api.py +24 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_browser.py +5 -3
- arrayview-0.49.1/src/arrayview/arrayview-opener.vsix +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/.gitignore +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/LICENSE +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/README.md +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/ARCHITECTURE.md +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/__init__.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/__main__.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_analysis.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_app.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_bootstrap_app.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_config.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_diagnostics.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_dicom.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_diff.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_extra_ports.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_icon.png +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_imaging.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_instance_registry.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_io.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_launch_plan.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_launch_trace.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_launcher.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_lifecycle.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_overlays.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_platform.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_render.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_analysis.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_drop.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_export.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_persistence.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_preferences.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_preload.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_query.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_rendering.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_segmentation.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_state.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_routes_vectorfield.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_segmentation.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_server.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_session.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_session_spec.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_shell.html +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_source_safety.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_synthetic_mri.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_torch.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_tutorial.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vectorfield.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_view_memory.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_vscode_browser.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/_watch.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/gsap.min.js +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/src/arrayview/todo.md +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/capture_patient_loading.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/capture_v_animation.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/conftest.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/make_vectorfield_test_arrays.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_1d_arrays.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_backend_shared.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_cli.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_config.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_dicom.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_drop_import.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_instance_registry.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_io.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_large_arrays.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_launch_blackbox.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_mode_roundtrip.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_nifti_meta.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_nifti_series.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_rgb_pixel_art.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_safe_server_stop.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_source_safety.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_torch.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_tutorial.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_view_memory.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/test_vsix_bundle.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/ui_audit.py +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/v_anim_frames/.gitkeep +0 -0
- {arrayview-0.49.1 → arrayview-0.50.1}/tests/visual_smoke.py +0 -0
|
@@ -630,6 +630,26 @@ def register_loading_routes(app, *, notify_shells, setup_rgb) -> None:
|
|
|
630
630
|
revealed = await asyncio.to_thread(_request_vscode_tab_reveal, *target)
|
|
631
631
|
return {"sid": sid, "revealed": revealed}
|
|
632
632
|
|
|
633
|
+
@app.post("/popout/{sid}")
|
|
634
|
+
async def toggle_session_tab_window(sid: str, request: Request):
|
|
635
|
+
"""Move this VS Code browser tab into its own window, or back."""
|
|
636
|
+
from arrayview._routes_websocket import viewer_tab_window_id
|
|
637
|
+
from arrayview._vscode_signal import _request_vscode_tab_reveal
|
|
638
|
+
|
|
639
|
+
try:
|
|
640
|
+
body = await request.json()
|
|
641
|
+
except Exception:
|
|
642
|
+
body = {}
|
|
643
|
+
path = body.get("path") if isinstance(body, dict) else None
|
|
644
|
+
window_id = viewer_tab_window_id(sid, path) if isinstance(path, str) else None
|
|
645
|
+
# A bare "/" matches every tab; only the query singles this one out.
|
|
646
|
+
if window_id is None or not urllib.parse.urlsplit(path).query:
|
|
647
|
+
return {"sid": sid, "moved": False}
|
|
648
|
+
moved = await asyncio.to_thread(
|
|
649
|
+
_request_vscode_tab_reveal, window_id, path, 5.0, "toggle-window"
|
|
650
|
+
)
|
|
651
|
+
return {"sid": sid, "moved": moved}
|
|
652
|
+
|
|
633
653
|
@app.post("/release/{sid}")
|
|
634
654
|
def release_viewer_session(sid: str, request: Request):
|
|
635
655
|
"""Release a session when its owning viewer tab/window closes."""
|
|
@@ -103,12 +103,22 @@ def _cancel_pending_viewer_release(sid: str) -> None:
|
|
|
103
103
|
task.cancel()
|
|
104
104
|
|
|
105
105
|
|
|
106
|
-
def _trace_viewer_event(event: str, sid: str) -> None:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
def _trace_viewer_event(event: str, sid: str, **attrs: object) -> None:
|
|
107
|
+
# Same opt-in as the page-route events (env or ~/.arrayview marker), so a
|
|
108
|
+
# server started by an Explorer click logs its sockets too: a launch that
|
|
109
|
+
# loads its page but never shows a frame needs both halves to diagnose.
|
|
110
|
+
try:
|
|
111
|
+
from arrayview._launch_trace import (
|
|
112
|
+
configure_route_launch_trace,
|
|
113
|
+
emit_launch_event,
|
|
114
|
+
trace_tag,
|
|
115
|
+
)
|
|
110
116
|
|
|
111
|
-
|
|
117
|
+
if not configure_route_launch_trace():
|
|
118
|
+
return
|
|
119
|
+
emit_launch_event(event, sid_tag=trace_tag(sid), **attrs)
|
|
120
|
+
except Exception:
|
|
121
|
+
return
|
|
112
122
|
|
|
113
123
|
def _render_progress_reporter(
|
|
114
124
|
loop, ws, label="Loading", quiet_for_s=0.6, min_interval_s=0.2
|
|
@@ -316,6 +326,14 @@ def viewer_tab_reveal_target(sid: str) -> tuple[str, str] | None:
|
|
|
316
326
|
return None
|
|
317
327
|
|
|
318
328
|
|
|
329
|
+
def viewer_tab_window_id(sid: str, path: str) -> str | None:
|
|
330
|
+
"""VS Code window that owns the live tab at *path* showing *sid*."""
|
|
331
|
+
for tab_sid, tab_path, window_id in _VIEWER_TAB_ADDRESSES.values():
|
|
332
|
+
if tab_sid == sid and tab_path == path:
|
|
333
|
+
return window_id
|
|
334
|
+
return None
|
|
335
|
+
|
|
336
|
+
|
|
319
337
|
def _handle_link_message(ws, msg: dict) -> None:
|
|
320
338
|
action = msg.get("action")
|
|
321
339
|
if action == "join":
|
|
@@ -469,11 +487,21 @@ def register_websocket_routes(app) -> None:
|
|
|
469
487
|
# stops that clock. The viewer keeps its splash up until the metadata
|
|
470
488
|
# below arrives, so an early accept shows nothing half-rendered.
|
|
471
489
|
await ws.accept()
|
|
490
|
+
accepted_at = time.perf_counter()
|
|
491
|
+
arrived_on = (ws.scope.get("server") or (None, None))[1]
|
|
492
|
+
_trace_viewer_event(
|
|
493
|
+
"viewer.socket_accepted", sid,
|
|
494
|
+
request_id=launch_request_id, port=arrived_on,
|
|
495
|
+
session_ready=sid in _session_mod.SESSIONS,
|
|
496
|
+
)
|
|
472
497
|
|
|
473
498
|
session = _session_mod.SESSIONS.get(sid)
|
|
474
499
|
if session is None:
|
|
475
500
|
session = await _await_session_reporting_read_progress(ws, sid)
|
|
476
501
|
if not session:
|
|
502
|
+
_trace_viewer_event(
|
|
503
|
+
"viewer.session_missing", sid, request_id=launch_request_id
|
|
504
|
+
)
|
|
477
505
|
# The socket has already opened, and the viewer treats a drop after
|
|
478
506
|
# a successful open as a recoverable reconnect — it would retry
|
|
479
507
|
# forever. Say the load failed so it shows an error instead.
|
|
@@ -497,6 +525,11 @@ def register_websocket_routes(app) -> None:
|
|
|
497
525
|
or launch_journal is None
|
|
498
526
|
or launch_journal.get("token") != launch_token
|
|
499
527
|
):
|
|
528
|
+
_trace_viewer_event(
|
|
529
|
+
"viewer.launch_rejected", sid,
|
|
530
|
+
request_id=launch_request_id,
|
|
531
|
+
journal_found=launch_journal is not None,
|
|
532
|
+
)
|
|
500
533
|
await ws.close(code=1008)
|
|
501
534
|
return
|
|
502
535
|
|
|
@@ -525,10 +558,9 @@ def register_websocket_routes(app) -> None:
|
|
|
525
558
|
_session_mod.VIEWER_CONNECTIONS_SEEN += 1
|
|
526
559
|
_session_mod.LIVE_VIEWER_SOCKETS.append(ws)
|
|
527
560
|
_ensure_path_nudge_task()
|
|
528
|
-
#
|
|
529
|
-
# released the moment its own viewer goes away
|
|
530
|
-
# every viewer everywhere to close.
|
|
531
|
-
arrived_on = (ws.scope.get("server") or (None, None))[1]
|
|
561
|
+
# arrived_on (above) is the port this viewer came in on, so a
|
|
562
|
+
# cold-start port can be released the moment its own viewer goes away
|
|
563
|
+
# rather than waiting for every viewer everywhere to close.
|
|
532
564
|
try:
|
|
533
565
|
from arrayview import _extra_ports
|
|
534
566
|
|
|
@@ -544,7 +576,11 @@ def register_websocket_routes(app) -> None:
|
|
|
544
576
|
_session_mod.VIEWER_REQUEST_COUNTS[request_key] = (
|
|
545
577
|
_session_mod.VIEWER_REQUEST_COUNTS.get(request_key, 0) + 1
|
|
546
578
|
)
|
|
547
|
-
_trace_viewer_event(
|
|
579
|
+
_trace_viewer_event(
|
|
580
|
+
"viewer.connected", sid,
|
|
581
|
+
request_id=launch_request_id,
|
|
582
|
+
wait_ms=round((time.perf_counter() - accepted_at) * 1000),
|
|
583
|
+
)
|
|
548
584
|
loop = asyncio.get_running_loop()
|
|
549
585
|
_pending: asyncio.Queue[dict | None] = asyncio.Queue()
|
|
550
586
|
|
|
@@ -572,11 +608,16 @@ def register_websocket_routes(app) -> None:
|
|
|
572
608
|
|
|
573
609
|
recv_task = asyncio.create_task(_receiver())
|
|
574
610
|
previous_indices = None
|
|
611
|
+
frames_sent = 0
|
|
575
612
|
try:
|
|
576
613
|
while True:
|
|
577
614
|
msg = await _pending.get()
|
|
578
615
|
if msg is None:
|
|
579
616
|
break
|
|
617
|
+
if frames_sent == 0:
|
|
618
|
+
_trace_viewer_event(
|
|
619
|
+
"viewer.first_request", sid, request_id=launch_request_id
|
|
620
|
+
)
|
|
580
621
|
|
|
581
622
|
seq = int(msg.get("seq", 0))
|
|
582
623
|
|
|
@@ -858,6 +899,14 @@ def register_websocket_routes(app) -> None:
|
|
|
858
899
|
raw_payload = payload
|
|
859
900
|
payload = await _render(loop, lambda: _pack_frame(raw_payload))
|
|
860
901
|
await ws.send_bytes(payload)
|
|
902
|
+
frames_sent += 1
|
|
903
|
+
if frames_sent == 1:
|
|
904
|
+
_trace_viewer_event(
|
|
905
|
+
"viewer.first_frame_sent", sid,
|
|
906
|
+
request_id=launch_request_id,
|
|
907
|
+
ms=round((time.perf_counter() - accepted_at) * 1000),
|
|
908
|
+
payload_bytes=len(payload),
|
|
909
|
+
)
|
|
861
910
|
if perf:
|
|
862
911
|
total_ms = (time.perf_counter() - total_t0) * 1000.0
|
|
863
912
|
await ws.send_json(
|
|
@@ -939,6 +988,13 @@ def register_websocket_routes(app) -> None:
|
|
|
939
988
|
|
|
940
989
|
_vprint(f"[ArrayView] WS/{sid[:8]}: {_ws_exc}", flush=True)
|
|
941
990
|
traceback.print_exc()
|
|
991
|
+
_trace_viewer_event(
|
|
992
|
+
"viewer.render_failed", sid,
|
|
993
|
+
request_id=launch_request_id,
|
|
994
|
+
error=type(_ws_exc).__name__,
|
|
995
|
+
message=str(_ws_exc)[:200],
|
|
996
|
+
frames_sent=frames_sent,
|
|
997
|
+
)
|
|
942
998
|
# Without this the viewer never learns the frame it is waiting for
|
|
943
999
|
# will not arrive: the socket just closes and the panel sits on the
|
|
944
1000
|
# loading spinner until the opener's timeout fails the request
|
|
@@ -1122,4 +1178,9 @@ def register_websocket_routes(app) -> None:
|
|
|
1122
1178
|
_session_mod.VIEWER_REQUEST_COUNTS[request_key] = request_count
|
|
1123
1179
|
else:
|
|
1124
1180
|
_session_mod.VIEWER_REQUEST_COUNTS.pop(request_key, None)
|
|
1125
|
-
_trace_viewer_event(
|
|
1181
|
+
_trace_viewer_event(
|
|
1182
|
+
"viewer.disconnected", sid,
|
|
1183
|
+
request_id=launch_request_id,
|
|
1184
|
+
frames_sent=frames_sent,
|
|
1185
|
+
open_s=round(time.perf_counter() - accepted_at, 1),
|
|
1186
|
+
)
|
|
@@ -3924,6 +3924,30 @@
|
|
|
3924
3924
|
.compare-title.drag-over { outline: 1px dashed var(--accent, #888); border-radius: 2px; }
|
|
3925
3925
|
.compare-title.compare-center-title { cursor: default; max-width: 100%; overflow: visible; text-overflow: clip; }
|
|
3926
3926
|
.compare-title.compare-side-title { gap: 0; }
|
|
3927
|
+
/* Array names: the start and end every compared name shares fade back
|
|
3928
|
+
and give way first when the slot is narrow (_fitArrayName). */
|
|
3929
|
+
.av-name { min-width: 0; overflow: hidden; white-space: nowrap; }
|
|
3930
|
+
.av-name-shared { opacity: 0.5; }
|
|
3931
|
+
#compare-view-wrap:not(.compare-titles-inset) .compare-title .av-name { color: color-mix(in srgb, var(--text) 75%, var(--muted)); }
|
|
3932
|
+
/* A name beside its image: plain text in the empty margin, wrapping
|
|
3933
|
+
at separators (_placeRowName / _placeCompareTitle). */
|
|
3934
|
+
.av-name.av-name-side {
|
|
3935
|
+
display: block; white-space: normal; overflow-wrap: anywhere;
|
|
3936
|
+
font-size: 12px; line-height: 16px; letter-spacing: 0.02em; text-transform: none;
|
|
3937
|
+
color: var(--text);
|
|
3938
|
+
}
|
|
3939
|
+
#compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-left,
|
|
3940
|
+
#compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-right {
|
|
3941
|
+
top: 50%; transform: translateY(-50%); max-width: none; -webkit-text-stroke: 0; text-shadow: none;
|
|
3942
|
+
}
|
|
3943
|
+
#compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-left { left: auto; right: 100%; margin-right: 14px; justify-content: flex-end; text-align: right; }
|
|
3944
|
+
#compare-view-wrap.compare-titles-inset .compare-title.compare-title-out-right { left: 100%; margin-left: 14px; text-align: left; }
|
|
3945
|
+
.compare-title.compare-title-out-left svg,
|
|
3946
|
+
.compare-title.compare-title-out-right svg { display: none; }
|
|
3947
|
+
.av-name-br { display: none; }
|
|
3948
|
+
/* Chrome breaks at <wbr> even under nowrap: only side names wrap */
|
|
3949
|
+
.av-name:not(.av-name-side) wbr { display: none; }
|
|
3950
|
+
.av-name.av-name-paths .av-name-br { display: block; }
|
|
3927
3951
|
.compare-diff-stats { display: inline-flex; gap: 12px; font-variant-numeric: tabular-nums; cursor: default; }
|
|
3928
3952
|
.compare-diff-stats b { font-weight: normal; color: color-mix(in srgb, var(--text) 75%, var(--muted)); text-transform: none; }
|
|
3929
3953
|
.detached-index-hint {
|
|
@@ -4212,6 +4236,8 @@
|
|
|
4212
4236
|
/* compare-qMRI: row label (array name) at left of each row */
|
|
4213
4237
|
.cq-row-label { font-size: 12px; color: var(--muted); letter-spacing: 0.08em; text-transform: uppercase; writing-mode: vertical-rl; transform: rotate(180deg); width: 24px; flex-shrink: 0; align-self: center; text-align: center; white-space: nowrap; }
|
|
4214
4238
|
.qv-row.cq-row > .cq-row-label { position: absolute; right: 100%; margin-right: 14px; top: 50%; transform: translateY(-50%) rotate(180deg); }
|
|
4239
|
+
/* compare-qMRI / compare-3plane: array name left of its row */
|
|
4240
|
+
.qv-row.cq-row > .cq-row-name { position: absolute; right: 100%; margin-right: 14px; top: 50%; transform: translateY(-50%); text-align: right; cursor: default; user-select: none; }
|
|
4215
4241
|
/* compare-qMRI: column header row for map names */
|
|
4216
4242
|
.cq-header-row { display: flex; flex-direction: row; gap: 24px; justify-content: center; align-items: flex-end; padding-left: 0; }
|
|
4217
4243
|
.cq-header-label { font-size: 16px; color: var(--fg); letter-spacing: 0.05em; text-align: center; }
|
|
@@ -4754,7 +4780,8 @@
|
|
|
4754
4780
|
/* qMRI maps touching (Preferences): no gaps; with two rows the
|
|
4755
4781
|
colour bars float inside the maps like the immersive islands. */
|
|
4756
4782
|
body.qmri-gapless #qmri-view-wrap.active { gap: 0 !important; }
|
|
4757
|
-
body.qmri-gapless #qmri-view-wrap > .qv-row:not(.qmri-mosaic-row)
|
|
4783
|
+
body.qmri-gapless #qmri-view-wrap > .qv-row:not(.qmri-mosaic-row),
|
|
4784
|
+
body.qmri-gapless #qmri-view-wrap > .cq-header-row { gap: 0 !important; }
|
|
4758
4785
|
body.qmri-gapless #qmri-view-wrap .qv-pane { gap: 0; }
|
|
4759
4786
|
body.qmri-gapless #qmri-view-wrap .qv-canvas-clip { border-radius: 0 !important; }
|
|
4760
4787
|
/* Touching panes read as one block: only its outer corners stay
|
|
@@ -9977,6 +10004,231 @@
|
|
|
9977
10004
|
return sidVal.slice(0, 8);
|
|
9978
10005
|
}
|
|
9979
10006
|
|
|
10007
|
+
// Compared names often share most of their text (one file name in
|
|
10008
|
+
// several run folders). Split each into the start and end that every
|
|
10009
|
+
// name shares and the middle that tells it apart. Splits only at
|
|
10010
|
+
// separators, and every name keeps a word of its own.
|
|
10011
|
+
const _NAME_SEP_RE = /^[_\-.\/\\\s()]+$/;
|
|
10012
|
+
function _arrayNameParts(names) {
|
|
10013
|
+
if (!names.length) return [];
|
|
10014
|
+
const toks = names.map(n => String(n).split(/([_\-.\/\\\s()]+)/).filter(Boolean));
|
|
10015
|
+
const hasWord = ts => ts.some(t => !_NAME_SEP_RE.test(t));
|
|
10016
|
+
let h = 0, t = 0;
|
|
10017
|
+
if (toks.length > 1) {
|
|
10018
|
+
const minLen = Math.min(...toks.map(ts => ts.length));
|
|
10019
|
+
while (h < minLen && toks.every(ts => ts[h] === toks[0][h])) h++;
|
|
10020
|
+
while (h > 0 && !toks.every(ts => hasWord(ts.slice(h)))) h--;
|
|
10021
|
+
const rest = toks.map(ts => ts.slice(h));
|
|
10022
|
+
const minRest = Math.min(...rest.map(ts => ts.length));
|
|
10023
|
+
const fromEnd = (ts, i) => ts[ts.length - 1 - i];
|
|
10024
|
+
while (t < minRest && rest.every(ts => fromEnd(ts, t) === fromEnd(rest[0], t))) t++;
|
|
10025
|
+
while (t > 0 && !rest.every(ts => hasWord(ts.slice(0, ts.length - t)))) t--;
|
|
10026
|
+
}
|
|
10027
|
+
// `near`: how much of each key's start it shares with its closest
|
|
10028
|
+
// sibling; in a tight slot that goes before the rest of the key.
|
|
10029
|
+
let keys = toks.map(ts => ts.slice(h, ts.length - t));
|
|
10030
|
+
// Shared folders and a shared file name or extension are left out
|
|
10031
|
+
// (the full path is on hover), but never part of a word group
|
|
10032
|
+
// (pat_a stays pat_a), nor when what remains says nothing on its
|
|
10033
|
+
// own (t1.npy (1) would become "1").
|
|
10034
|
+
const isPath = tok => /[\/\\]/.test(tok);
|
|
10035
|
+
let hd = h;
|
|
10036
|
+
while (hd > 0 && !isPath(toks[0][hd - 1])) hd--;
|
|
10037
|
+
const tailToks = toks[0].slice(toks[0].length - t);
|
|
10038
|
+
const cut = tailToks.findIndex(tok => isPath(tok) || tok.startsWith('.'));
|
|
10039
|
+
const td = cut < 0 ? 0 : t - cut;
|
|
10040
|
+
const shortKeys = toks.map(ts => ts.slice(hd, ts.length - td));
|
|
10041
|
+
const dropShared = shortKeys.every(kt => kt.some(tok => /[a-z]/i.test(tok)));
|
|
10042
|
+
if (dropShared) {
|
|
10043
|
+
keys = shortKeys.map(kt => {
|
|
10044
|
+
let a = 0, b = kt.length;
|
|
10045
|
+
const loose = tok => /^[_\-.\/\\\s]+$/.test(tok);
|
|
10046
|
+
while (a < b && loose(kt[a])) a++;
|
|
10047
|
+
while (b > a && loose(kt[b - 1])) b--;
|
|
10048
|
+
return kt.slice(a, b);
|
|
10049
|
+
});
|
|
10050
|
+
}
|
|
10051
|
+
return toks.map((ts, i) => {
|
|
10052
|
+
let near = 0;
|
|
10053
|
+
keys.forEach((other, j) => {
|
|
10054
|
+
if (j === i) return;
|
|
10055
|
+
let n = 0;
|
|
10056
|
+
while (n < keys[i].length && keys[i][n] === other[n]) n++;
|
|
10057
|
+
while (n > 0 && !hasWord(keys[i].slice(n))) n--;
|
|
10058
|
+
near = Math.max(near, n);
|
|
10059
|
+
});
|
|
10060
|
+
return {
|
|
10061
|
+
head: dropShared ? '' : ts.slice(0, h).join(''),
|
|
10062
|
+
key: keys[i].join(''),
|
|
10063
|
+
tail: dropShared ? '' : ts.slice(ts.length - t).join(''),
|
|
10064
|
+
near: keys[i].slice(0, near).join('').length,
|
|
10065
|
+
};
|
|
10066
|
+
});
|
|
10067
|
+
}
|
|
10068
|
+
|
|
10069
|
+
// Last `n` characters of `s`, moved up to the next word start when the
|
|
10070
|
+
// cut would leave a word fragment (\u2026on_ reads as noise).
|
|
10071
|
+
function _nameTailChars(s, n) {
|
|
10072
|
+
const out = s.slice(s.length - n);
|
|
10073
|
+
if (n >= s.length || /[_\-.\/\\\s()]/.test(s[s.length - n - 1])) return out;
|
|
10074
|
+
const m = out.match(/[_\-.\/\\\s()]+/);
|
|
10075
|
+
return m ? out.slice(m.index + m[0].length) : '';
|
|
10076
|
+
}
|
|
10077
|
+
|
|
10078
|
+
// Name squeezed to `cap` characters: the shared end gives way first,
|
|
10079
|
+
// then the shared start, then the start the name shares with its
|
|
10080
|
+
// closest sibling, and only then the middle of what is its own.
|
|
10081
|
+
// Returns [text, shared] runs.
|
|
10082
|
+
function _arrayNameRuns(p, cap) {
|
|
10083
|
+
const E = '\u2026';
|
|
10084
|
+
const { head, key, tail, near } = p;
|
|
10085
|
+
if (head.length + key.length + tail.length <= cap) return [[head, true], [key, false], [tail, true]];
|
|
10086
|
+
const tailRoom = cap - head.length - key.length - 1;
|
|
10087
|
+
if (tail && tailRoom >= 0) return [[head, true], [key, false], [tail.slice(0, tailRoom) + E, true]];
|
|
10088
|
+
const tailE = tail ? E : '';
|
|
10089
|
+
const headRoom = cap - key.length - tailE.length - 1;
|
|
10090
|
+
if (head && headRoom >= 0) return [[E + _nameTailChars(head, headRoom), true], [key, false], [tailE, true]];
|
|
10091
|
+
if (!head && key.length + tailE.length <= cap) return [[key, false], [tailE, true]];
|
|
10092
|
+
const keyRoom = cap - 1 - tailE.length;
|
|
10093
|
+
if (keyRoom > 0 && key.length - keyRoom <= near) {
|
|
10094
|
+
const cut = _nameTailChars(key, keyRoom);
|
|
10095
|
+
return [[E, true], [cut || key.slice(key.length - keyRoom), false], [tailE, true]];
|
|
10096
|
+
}
|
|
10097
|
+
const rest = key.slice(near).replace(/^[_\-.\/\\\s()]+/, '');
|
|
10098
|
+
const lead = (head || near) ? E : '';
|
|
10099
|
+
const room = cap - lead.length - tailE.length - 1;
|
|
10100
|
+
if (room < 2) return [[rest.slice(0, Math.max(1, cap - 1)), false], [E, true]];
|
|
10101
|
+
const a = Math.floor(room / 2), b = room - a;
|
|
10102
|
+
return [[lead, true], [rest.slice(0, a), false], [E, false], [rest.slice(rest.length - b), false], [tailE, true]];
|
|
10103
|
+
}
|
|
10104
|
+
|
|
10105
|
+
function _arrayNameHTML(runs) {
|
|
10106
|
+
// A name beside its image wraps after separators (not dots: they
|
|
10107
|
+
// sit inside numbers), and after folders when av-name-paths is on.
|
|
10108
|
+
const esc = t => t.replace(/&/g, '&').replace(/</g, '<')
|
|
10109
|
+
.replace(/[_\-\s()]+/g, '$&<wbr>')
|
|
10110
|
+
.replace(/[\/\\]+/g, '$&<span class="av-name-br"></span>');
|
|
10111
|
+
return runs.filter(([t]) => t)
|
|
10112
|
+
.map(([t, shared]) => shared ? `<span class="av-name-shared">${esc(t)}</span>` : esc(t))
|
|
10113
|
+
.join('');
|
|
10114
|
+
}
|
|
10115
|
+
|
|
10116
|
+
// Names sit beside their image, in the empty margin, when it has room
|
|
10117
|
+
// for a column at least NAME_SIDE_MIN_W wide; otherwise inside it.
|
|
10118
|
+
// NAME_SIDE_GAP is also the CSS margin between name and image.
|
|
10119
|
+
const NAME_SIDE_MIN_W = 96;
|
|
10120
|
+
const NAME_SIDE_MAX_W = 280;
|
|
10121
|
+
const NAME_SIDE_GAP = 14;
|
|
10122
|
+
const NAME_SIDE_EDGE = 12;
|
|
10123
|
+
// Margin a row layout keeps free on each side for its row names,
|
|
10124
|
+
// counted from the window edge. Capped so a narrow window keeps its
|
|
10125
|
+
// images and the names get a narrower column instead.
|
|
10126
|
+
function _rowNameReserve() {
|
|
10127
|
+
const view = document.getElementById('qmri-view-wrap');
|
|
10128
|
+
const viewL = view ? Math.max(0, view.getBoundingClientRect().left) : 0;
|
|
10129
|
+
return Math.min(viewL + NAME_SIDE_MIN_W + NAME_SIDE_GAP + NAME_SIDE_EDGE, window.innerWidth * 0.17);
|
|
10130
|
+
}
|
|
10131
|
+
|
|
10132
|
+
function _fitArrayName(el) {
|
|
10133
|
+
const p = el && el._nameParts;
|
|
10134
|
+
if (!p) return;
|
|
10135
|
+
if (el.isConnected && el._namePlace) el._namePlace(el);
|
|
10136
|
+
const full = p.head.length + p.key.length + p.tail.length;
|
|
10137
|
+
const show = cap => { el.innerHTML = _arrayNameHTML(_arrayNameRuns(p, cap)); };
|
|
10138
|
+
el.classList.remove('av-name-paths');
|
|
10139
|
+
show(full);
|
|
10140
|
+
if (!el.isConnected) return;
|
|
10141
|
+
const fits = () => el.scrollWidth <= el.clientWidth && el.scrollHeight <= el.clientHeight;
|
|
10142
|
+
// A name that needs several lines starts each folder on a new one,
|
|
10143
|
+
// unless that alone makes it too tall.
|
|
10144
|
+
if (el.classList.contains('av-name-side') && el.scrollHeight > 1.5 * parseFloat(getComputedStyle(el).lineHeight)) {
|
|
10145
|
+
el.classList.add('av-name-paths');
|
|
10146
|
+
if (!fits()) el.classList.remove('av-name-paths');
|
|
10147
|
+
}
|
|
10148
|
+
if (fits() || el.clientWidth <= 0) return;
|
|
10149
|
+
let lo = 1, hi = full - 1;
|
|
10150
|
+
while (lo < hi) {
|
|
10151
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
10152
|
+
show(mid);
|
|
10153
|
+
if (fits()) lo = mid; else hi = mid - 1;
|
|
10154
|
+
}
|
|
10155
|
+
show(lo);
|
|
10156
|
+
}
|
|
10157
|
+
|
|
10158
|
+
// Refit names when their pane, row or view changes size.
|
|
10159
|
+
const _arrayNameRO = typeof ResizeObserver === 'function'
|
|
10160
|
+
? new ResizeObserver(entries => {
|
|
10161
|
+
const names = new Set();
|
|
10162
|
+
for (const e of entries) {
|
|
10163
|
+
if (!e.target.isConnected) { _arrayNameRO.unobserve(e.target); continue; }
|
|
10164
|
+
e.target.querySelectorAll('.av-name').forEach(n => names.add(n));
|
|
10165
|
+
}
|
|
10166
|
+
names.forEach(_fitArrayName);
|
|
10167
|
+
})
|
|
10168
|
+
: null;
|
|
10169
|
+
|
|
10170
|
+
// Hover text for a shortened name: the file's full path when known.
|
|
10171
|
+
function _arrayNameHover(sidVal, label) {
|
|
10172
|
+
return _compareFilepathBySid[sidVal] || label;
|
|
10173
|
+
}
|
|
10174
|
+
|
|
10175
|
+
function _setArrayName(el, parts, fullName, slots, place) {
|
|
10176
|
+
el.classList.add('av-name');
|
|
10177
|
+
el._nameParts = parts;
|
|
10178
|
+
el._namePlace = place || null;
|
|
10179
|
+
el.title = fullName;
|
|
10180
|
+
_fitArrayName(el);
|
|
10181
|
+
if (_arrayNameRO) slots.forEach(slot => slot && _arrayNameRO.observe(slot));
|
|
10182
|
+
}
|
|
10183
|
+
|
|
10184
|
+
// compare-qMRI / compare-3plane: the name sits left of its row, in
|
|
10185
|
+
// the margin the layout keeps free (_rowNameReserve). The view
|
|
10186
|
+
// clips at its own edge, not the window's.
|
|
10187
|
+
function _placeRowName(el) {
|
|
10188
|
+
const r = el.parentElement.getBoundingClientRect();
|
|
10189
|
+
const viewL = document.getElementById('qmri-view-wrap').getBoundingClientRect().left;
|
|
10190
|
+
const w = Math.min(NAME_SIDE_MAX_W, r.left - viewL - NAME_SIDE_EDGE - NAME_SIDE_GAP);
|
|
10191
|
+
el.style.width = Math.max(40, w) + 'px';
|
|
10192
|
+
el.style.maxHeight = Math.max(16, r.height) + 'px';
|
|
10193
|
+
}
|
|
10194
|
+
|
|
10195
|
+
// Compare panes: names move into the margin beside the panes when
|
|
10196
|
+
// every pane has one (one or two columns) with room; otherwise they
|
|
10197
|
+
// all stay inside their images, never a mix.
|
|
10198
|
+
function _compareTitleSides() {
|
|
10199
|
+
const wrap = document.getElementById('compare-view-wrap');
|
|
10200
|
+
if (!wrap.classList.contains('compare-titles-inset')) return null;
|
|
10201
|
+
const panes = [...wrap.querySelectorAll('.compare-pane.active')]
|
|
10202
|
+
.filter(q => q.getBoundingClientRect().width > 0);
|
|
10203
|
+
if (!panes.length) return null;
|
|
10204
|
+
const rects = panes.map(q => q.getBoundingClientRect());
|
|
10205
|
+
const minL = Math.min(...rects.map(r => r.left));
|
|
10206
|
+
const maxR = Math.max(...rects.map(r => r.right));
|
|
10207
|
+
const roomL = minL - NAME_SIDE_EDGE - NAME_SIDE_GAP;
|
|
10208
|
+
const roomR = window.innerWidth - maxR - NAME_SIDE_EDGE - NAME_SIDE_GAP;
|
|
10209
|
+
const sides = new Map();
|
|
10210
|
+
for (let i = 0; i < panes.length; i++) {
|
|
10211
|
+
const r = rects[i];
|
|
10212
|
+
if (r.left <= minL + 1 && roomL >= NAME_SIDE_MIN_W) sides.set(panes[i], { side: 'left', w: roomL, h: r.height });
|
|
10213
|
+
else if (r.right >= maxR - 1 && roomR >= NAME_SIDE_MIN_W) sides.set(panes[i], { side: 'right', w: roomR, h: r.height });
|
|
10214
|
+
else return null;
|
|
10215
|
+
}
|
|
10216
|
+
return sides;
|
|
10217
|
+
}
|
|
10218
|
+
|
|
10219
|
+
function _placeCompareTitle(el) {
|
|
10220
|
+
const title = el.closest('.compare-title');
|
|
10221
|
+
const pane = title && title.closest('.compare-pane');
|
|
10222
|
+
const sides = pane && !title.classList.contains('compare-side-title') ? _compareTitleSides() : null;
|
|
10223
|
+
const spot = sides && sides.get(pane);
|
|
10224
|
+
const side = spot ? spot.side : null;
|
|
10225
|
+
title.classList.toggle('compare-title-out-left', side === 'left');
|
|
10226
|
+
title.classList.toggle('compare-title-out-right', side === 'right');
|
|
10227
|
+
el.classList.toggle('av-name-side', !!side);
|
|
10228
|
+
el.style.width = side ? Math.min(NAME_SIDE_MAX_W, spot.w) + 'px' : '';
|
|
10229
|
+
el.style.maxHeight = side ? spot.h + 'px' : '';
|
|
10230
|
+
}
|
|
10231
|
+
|
|
9980
10232
|
function _resetDetachedDimState() {
|
|
9981
10233
|
detachedDimMode = false;
|
|
9982
10234
|
detachedDim = -1;
|
|
@@ -10110,14 +10362,17 @@
|
|
|
10110
10362
|
drawRegBlendCb();
|
|
10111
10363
|
}
|
|
10112
10364
|
const _logoSvg = '<svg width="14" height="14" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0"><rect width="16" height="16" rx="2.5" fill="#0c0c0c"/><rect x="2" y="2" width="3" height="3" fill="#3a0ca3"/><rect x="6.5" y="2" width="3" height="3" fill="#560bad"/><rect x="11" y="2" width="3" height="3" fill="#c77dff"/><rect x="2" y="6.5" width="3" height="3" fill="#4361ee"/><rect x="6.5" y="6.5" width="3" height="3" fill="#4cc9f0"/><rect x="11" y="6.5" width="3" height="3" fill="#f5c842"/><rect x="2" y="11" width="3" height="3" fill="#4895ef"/><rect x="6.5" y="11" width="3" height="3" fill="#80ed99"/><rect x="11" y="11" width="3" height="3" fill="#f8961e"/></svg>';
|
|
10365
|
+
const nameParts = detachedDimMode ? null : _arrayNameParts(labels);
|
|
10113
10366
|
compareTitles.forEach((el, idx) => {
|
|
10114
10367
|
const text = labels[idx] || '';
|
|
10115
10368
|
const sideTitle = _isCompareBigLeftLayout() && idx < 2;
|
|
10116
10369
|
el.classList.toggle('compare-side-title', sideTitle);
|
|
10117
|
-
if (text) {
|
|
10118
|
-
|
|
10119
|
-
|
|
10120
|
-
el.innerHTML =
|
|
10370
|
+
if (text && detachedDimMode) {
|
|
10371
|
+
el.innerHTML = '<span>' + _detachedDimPaneTitleHTML(idx, text) + '</span>';
|
|
10372
|
+
} else if (text) {
|
|
10373
|
+
el.innerHTML = (sideTitle ? '' : _logoSvg) + '<span></span>';
|
|
10374
|
+
_setArrayName(el.lastElementChild, nameParts[idx], _arrayNameHover(effectiveSids[idx], text),
|
|
10375
|
+
[el.closest('.compare-pane'), document.getElementById('compare-view-wrap')], _placeCompareTitle);
|
|
10121
10376
|
} else {
|
|
10122
10377
|
el.textContent = '';
|
|
10123
10378
|
}
|
|
@@ -28366,6 +28621,25 @@
|
|
|
28366
28621
|
}
|
|
28367
28622
|
},
|
|
28368
28623
|
},
|
|
28624
|
+
'window.togglePopout': {
|
|
28625
|
+
title: 'Pop this tab out into its own window / put it back',
|
|
28626
|
+
when: [],
|
|
28627
|
+
run: async () => {
|
|
28628
|
+
if (!_vscodeTabWindowId) {
|
|
28629
|
+
showStatus('pop out: only in a VS Code tab');
|
|
28630
|
+
return;
|
|
28631
|
+
}
|
|
28632
|
+
try {
|
|
28633
|
+
const r = await proxyFetch(`/popout/${sid}`, {
|
|
28634
|
+
method: 'POST',
|
|
28635
|
+
headers: { 'Content-Type': 'application/json' },
|
|
28636
|
+
body: JSON.stringify({ path: location.pathname + location.search }),
|
|
28637
|
+
});
|
|
28638
|
+
if (r.ok && (await r.json()).moved) return;
|
|
28639
|
+
} catch (_) {}
|
|
28640
|
+
showStatus('pop out: VS Code did not respond');
|
|
28641
|
+
},
|
|
28642
|
+
},
|
|
28369
28643
|
'immersive.toggle': {
|
|
28370
28644
|
title: 'Toggle immersive mode',
|
|
28371
28645
|
when: [],
|
|
@@ -30226,6 +30500,7 @@
|
|
|
30226
30500
|
{ key: '-', command: 'zoom.out' },
|
|
30227
30501
|
{ key: '0', command: 'zoom.reset' },
|
|
30228
30502
|
{ key: 'F', command: 'immersive.toggle' },
|
|
30503
|
+
{ key: 'W', command: 'window.togglePopout' },
|
|
30229
30504
|
{ key: 'n', command: 'compare.cycleTarget' },
|
|
30230
30505
|
{ key: '<', command: 'playback.adjustFps' },
|
|
30231
30506
|
{ key: '>', command: 'playback.adjustFps' },
|
|
@@ -30399,6 +30674,7 @@
|
|
|
30399
30674
|
{ key: 'Drag', action: 'Pan the image (when zoomed in)' },
|
|
30400
30675
|
{ key: 'Shift+F', action: 'Toggle immersive mode', hint: 'hides chrome and fills the viewport with the array' },
|
|
30401
30676
|
{ key: 'Z', action: 'Zen mode — hide UI chrome', hint: 'in compare + center active, hides the two source panes instead' },
|
|
30677
|
+
{ key: 'Shift+W', action: 'Pop tab out into its own window / put it back', hint: 'VS Code tabs only' },
|
|
30402
30678
|
]
|
|
30403
30679
|
},
|
|
30404
30680
|
{
|
|
@@ -37735,12 +38011,6 @@
|
|
|
37735
38011
|
// Column header row with map names
|
|
37736
38012
|
const headerRow = document.createElement('div');
|
|
37737
38013
|
headerRow.className = 'cq-header-row';
|
|
37738
|
-
// Spacer matching row label width
|
|
37739
|
-
const headerSpacer = document.createElement('div');
|
|
37740
|
-
headerSpacer.className = 'cq-row-label';
|
|
37741
|
-
headerSpacer.style.visibility = 'hidden';
|
|
37742
|
-
headerSpacer.textContent = '\u00A0';
|
|
37743
|
-
headerRow.appendChild(headerSpacer);
|
|
37744
38014
|
for (let mi = 0; mi < nMaps; mi++) {
|
|
37745
38015
|
const hLabel = document.createElement('div');
|
|
37746
38016
|
hLabel.className = 'cq-header-label qv-label';
|
|
@@ -37750,16 +38020,18 @@
|
|
|
37750
38020
|
wrap.appendChild(headerRow);
|
|
37751
38021
|
|
|
37752
38022
|
compareQmriViews = [];
|
|
38023
|
+
const rowNames = compareQmriSids.map(comparePaneLabelForSid);
|
|
38024
|
+
const rowNameParts = _arrayNameParts(rowNames);
|
|
37753
38025
|
// One row per compared array (SID)
|
|
37754
38026
|
for (let si = 0; si < compareQmriSids.length; si++) {
|
|
37755
38027
|
const sidVal = compareQmriSids[si];
|
|
37756
38028
|
const rowDiv = document.createElement('div');
|
|
37757
38029
|
rowDiv.className = 'qv-row cq-row';
|
|
37758
|
-
//
|
|
37759
|
-
const
|
|
37760
|
-
|
|
37761
|
-
|
|
37762
|
-
|
|
38030
|
+
// Array name, left of its row
|
|
38031
|
+
const rowName = document.createElement('div');
|
|
38032
|
+
rowName.className = 'cq-row-name av-name-side';
|
|
38033
|
+
rowDiv.appendChild(rowName);
|
|
38034
|
+
_setArrayName(rowName, rowNameParts[si], _arrayNameHover(sidVal, rowNames[si]), [rowDiv, wrap], _placeRowName);
|
|
37763
38035
|
for (let mi = 0; mi < nMaps; mi++) {
|
|
37764
38036
|
const actualIdx = mapIndices[mi];
|
|
37765
38037
|
const cmap = cmaps[mi];
|
|
@@ -38057,7 +38329,7 @@
|
|
|
38057
38329
|
const rows = nRows;
|
|
38058
38330
|
const colGap = rows >= 3 ? 10 : 24;
|
|
38059
38331
|
const rowGap = rows >= 3 ? 4 : 14;
|
|
38060
|
-
const sidePad = rows >= 3 ? 80 : 120;
|
|
38332
|
+
const sidePad = Math.max(rows >= 3 ? 80 : 120, 2 * _rowNameReserve()); // room for row names
|
|
38061
38333
|
const labelH = 24; // column headers
|
|
38062
38334
|
const anyExpanded = ready.some(v => v._colorBar && v._colorBar._expanded);
|
|
38063
38335
|
const cbH = anyExpanded ? 33 + (CB_DEFAULT_EXPANDED_H - CB_COLLAPSED_H) : 33;
|
|
@@ -38286,23 +38558,18 @@
|
|
|
38286
38558
|
}
|
|
38287
38559
|
|
|
38288
38560
|
compareMvViews = [];
|
|
38561
|
+
const rowNames = compareMvSids.map(comparePaneLabelForSid);
|
|
38562
|
+
const rowNameParts = _arrayNameParts(rowNames);
|
|
38289
38563
|
// One row per compared array (SID)
|
|
38290
38564
|
for (let si = 0; si < compareMvSids.length; si++) {
|
|
38291
38565
|
const sidVal = compareMvSids[si];
|
|
38292
38566
|
const rowDiv = document.createElement('div');
|
|
38293
38567
|
rowDiv.className = 'qv-row cq-row';
|
|
38294
|
-
//
|
|
38295
|
-
const
|
|
38296
|
-
|
|
38297
|
-
|
|
38298
|
-
|
|
38299
|
-
// Basenames alone, unless that would make two rows look alike;
|
|
38300
|
-
// then keep the folders the pane titles use to tell them apart.
|
|
38301
|
-
const _shorts = compareMvSids.map(x => _shortLabel(comparePaneLabelForSid(x)));
|
|
38302
|
-
rowLabel.textContent = _shorts.filter(x => x === _shorts[si]).length > 1
|
|
38303
|
-
? _rawLabel.replace(/\.[^./\\\s]+(\s\(\d+\))?$/, '$1') : _shorts[si];
|
|
38304
|
-
rowLabel.title = _rawLabel; // full name on hover
|
|
38305
|
-
rowDiv.appendChild(rowLabel);
|
|
38568
|
+
// Array name, left of its row
|
|
38569
|
+
const rowName = document.createElement('div');
|
|
38570
|
+
rowName.className = 'cq-row-name av-name-side';
|
|
38571
|
+
rowDiv.appendChild(rowName);
|
|
38572
|
+
_setArrayName(rowName, rowNameParts[si], _arrayNameHover(sidVal, rowNames[si]), [rowDiv, wrap], _placeRowName);
|
|
38306
38573
|
// 3 planes per row
|
|
38307
38574
|
for (let pi = 0; pi < planeDefs.length; pi++) {
|
|
38308
38575
|
const def = planeDefs[pi];
|
|
@@ -38684,7 +38951,7 @@
|
|
|
38684
38951
|
const nCols = 3; // always 3 planes
|
|
38685
38952
|
const colGap = nRows >= 3 ? 12 : 24;
|
|
38686
38953
|
const rowGap = nRows >= 3 ? 6 : 14;
|
|
38687
|
-
const sidePad = nRows >= 3 ? 80 : 120; //
|
|
38954
|
+
const sidePad = Math.max(nRows >= 3 ? 80 : 120, 2 * _rowNameReserve()); // room for row names
|
|
38688
38955
|
const labelH = 0; // no column headers in compare-multiview
|
|
38689
38956
|
const cbWrap = document.getElementById('mv-cb-wrap');
|
|
38690
38957
|
const cbH = cbWrap && colorbarVisible() ? (cbWrap.offsetHeight || 33) : 0;
|
|
@@ -43044,7 +43311,11 @@
|
|
|
43044
43311
|
|
|
43045
43312
|
function _reconcileCompareTitlesInset() {
|
|
43046
43313
|
compareWrap.classList.toggle('compare-touching', _compareTouching());
|
|
43047
|
-
|
|
43314
|
+
const inset = _compareTitlesInset();
|
|
43315
|
+
if (inset !== compareWrap.classList.contains('compare-titles-inset')) {
|
|
43316
|
+
compareWrap.classList.toggle('compare-titles-inset', inset);
|
|
43317
|
+
compareWrap.querySelectorAll('.av-name').forEach(_fitArrayName);
|
|
43318
|
+
}
|
|
43048
43319
|
}
|
|
43049
43320
|
|
|
43050
43321
|
// ── Centralized CB / island visibility reconciler ────────────
|
|
@@ -58,7 +58,7 @@ _VSCODE_EXT_FRESH_INSTALL = False # True if we just installed it this session
|
|
|
58
58
|
_VSCODE_EXT_RELOAD_REQUIRED = False # installed files are newer than the live host
|
|
59
59
|
_VSCODE_EXT_INSTALL_FAILED = False # automatic install could not complete safely
|
|
60
60
|
_VSCODE_EXT_NO_LIVE_WINDOW = False # no live host claims this terminal's window id
|
|
61
|
-
_VSCODE_EXT_VERSION = "0.15.
|
|
61
|
+
_VSCODE_EXT_VERSION = "0.15.74" # current bundled extension version
|
|
62
62
|
_VSCODE_CONFIGURED_PORTS: set[int] = set()
|
|
63
63
|
# Version-skew notices already printed, so a long-lived process (Jupyter, a
|
|
64
64
|
# script opening several arrays) states the mismatch once instead of per call.
|
|
@@ -694,10 +694,13 @@ _VSCODE_REVEAL_ACK_PREFIX = "reveal-tab-ack-"
|
|
|
694
694
|
|
|
695
695
|
|
|
696
696
|
def _request_vscode_tab_reveal(
|
|
697
|
-
window_id: str, url_filter: str, timeout: float = 2.5
|
|
697
|
+
window_id: str, url_filter: str, timeout: float = 2.5, action: str = "reveal"
|
|
698
698
|
) -> bool:
|
|
699
699
|
"""Ask the opener in *window_id* to bring the browser tab matching
|
|
700
|
-
*url_filter* forward. True only when that window confirms it did.
|
|
700
|
+
*url_filter* forward. True only when that window confirms it did.
|
|
701
|
+
|
|
702
|
+
``action="toggle-window"`` also moves that tab into its own window, or
|
|
703
|
+
back into the main window when it was popped out before."""
|
|
701
704
|
signal_dir = Path(os.path.expanduser("~/.arrayview"))
|
|
702
705
|
request_id = uuid.uuid4().hex
|
|
703
706
|
request_path = signal_dir / f"{_VSCODE_REVEAL_REQUEST_PREFIX}{request_id}.json"
|
|
@@ -706,6 +709,7 @@ def _request_vscode_tab_reveal(
|
|
|
706
709
|
"requestId": request_id,
|
|
707
710
|
"windowId": window_id,
|
|
708
711
|
"urlFilter": url_filter,
|
|
712
|
+
"action": action,
|
|
709
713
|
"sentAtMs": int(time.time() * 1000),
|
|
710
714
|
"maxAgeMs": int(timeout * 1000),
|
|
711
715
|
}
|
|
Binary file
|
|
@@ -4536,3 +4536,27 @@ class TestRevealTab:
|
|
|
4536
4536
|
assert c.post(f"/reveal/{sid}").json()["revealed"] is True
|
|
4537
4537
|
assert asked == [("w1", "/?t=ab12c")]
|
|
4538
4538
|
assert c.post(f"/reveal/{sid}").json()["revealed"] is False
|
|
4539
|
+
|
|
4540
|
+
def test_popout_asks_the_window_owning_this_tab(self, tmp_path, monkeypatch):
|
|
4541
|
+
"""Shift+W asks the VS Code window holding this exact tab to move it."""
|
|
4542
|
+
from arrayview._app import app
|
|
4543
|
+
import arrayview._vscode_signal as signal_mod
|
|
4544
|
+
|
|
4545
|
+
asked = []
|
|
4546
|
+
monkeypatch.setattr(
|
|
4547
|
+
signal_mod, "_request_vscode_tab_reveal",
|
|
4548
|
+
lambda *args: asked.append(args) or True,
|
|
4549
|
+
)
|
|
4550
|
+
np.save(tmp_path / "pop.npy", np.zeros((4, 4), dtype=np.float32))
|
|
4551
|
+
with TestClient(app) as c:
|
|
4552
|
+
sid = c.post("/load", json={"filepath": str(tmp_path / "pop.npy")}).json()["sid"]
|
|
4553
|
+
pop = lambda path: c.post(f"/popout/{sid}", json={"path": path}).json()["moved"]
|
|
4554
|
+
assert pop("/?t=ab12c") is False
|
|
4555
|
+
with c.websocket_connect(f"/ws/{sid}") as ws:
|
|
4556
|
+
ws.receive_json()
|
|
4557
|
+
ws.send_json({"type": "tab-address", "path": "/?t=ab12c", "window_id": "w1"})
|
|
4558
|
+
ws.send_json({"type": "link", "action": "leave"}) # round-trip barrier
|
|
4559
|
+
import time; time.sleep(0.2)
|
|
4560
|
+
assert pop("/?t=other") is False
|
|
4561
|
+
assert pop("/?t=ab12c") is True
|
|
4562
|
+
assert asked == [("w1", "/?t=ab12c", 5.0, "toggle-window")]
|
|
@@ -1234,7 +1234,7 @@ class TestModeCombinations:
|
|
|
1234
1234
|
page.wait_for_function("() => compareActive && compareMvActive", timeout=15_000)
|
|
1235
1235
|
# Panes are titled with the file names, not session ids.
|
|
1236
1236
|
page.wait_for_function(
|
|
1237
|
-
"() => [...document.querySelectorAll('.cq-row-
|
|
1237
|
+
"() => [...document.querySelectorAll('.cq-row-name')].map(e => e.textContent).join(' ').includes('cmportho0')",
|
|
1238
1238
|
timeout=5_000,
|
|
1239
1239
|
)
|
|
1240
1240
|
|
|
@@ -1341,8 +1341,10 @@ class TestModeCombinations:
|
|
|
1341
1341
|
np.save(path, arr_3d)
|
|
1342
1342
|
sids.append(client.post("/load", json={"filepath": str(path), "name": "t1.npy"}).json()["sid"])
|
|
1343
1343
|
page.goto(f"{server_url}/?sid={sids[0]}&compare_sid={sids[1]}&compare_sids={sids[1]}")
|
|
1344
|
+
# Only the part that differs is shown; the full path is on hover.
|
|
1344
1345
|
page.wait_for_function(
|
|
1345
|
-
"""() => { const
|
|
1346
|
-
return
|
|
1346
|
+
"""() => { const n = document.querySelector('#compare-left-title .av-name');
|
|
1347
|
+
return n && n.textContent === 'pat_a'
|
|
1348
|
+
&& n.title.endsWith('pat_a/t1.npy'); }""",
|
|
1347
1349
|
timeout=15_000,
|
|
1348
1350
|
)
|
|
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
|