brkraw-viewer 0.2.5__py3-none-any.whl

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.
Binary file
Binary file
@@ -0,0 +1,2 @@
1
+ """Reusable UI frames for BrkRaw Viewer."""
2
+
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk
5
+ from typing import Any, Dict, Iterable, Optional
6
+
7
+
8
+ class ParamsPanel(ttk.Frame):
9
+ def __init__(self, parent: tk.Widget) -> None:
10
+ super().__init__(parent)
11
+ self.columnconfigure(0, weight=1)
12
+ self.rowconfigure(1, weight=1)
13
+
14
+ self._search_var = tk.StringVar(value="")
15
+
16
+ search_bar = ttk.Frame(self)
17
+ search_bar.grid(row=0, column=0, sticky="ew", pady=(0, 6))
18
+ search_bar.columnconfigure(1, weight=1)
19
+
20
+ ttk.Label(search_bar, text="Search").grid(row=0, column=0, sticky="w")
21
+ entry = ttk.Entry(search_bar, textvariable=self._search_var)
22
+ entry.grid(row=0, column=1, sticky="ew", padx=(6, 0))
23
+ entry.bind("<Return>", self._on_search)
24
+ ttk.Button(search_bar, text="Go", command=self._on_search).grid(row=0, column=2, padx=(6, 0))
25
+
26
+ self._listbox = tk.Listbox(self, exportselection=False, height=10)
27
+ self._listbox.grid(row=1, column=0, sticky="nsew")
28
+ self._listbox.bind("<<ListboxSelect>>", self._on_select)
29
+
30
+ self._text = tk.Text(self, wrap="word", height=12)
31
+ self._text.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
32
+ self._text.configure(state=tk.DISABLED)
33
+
34
+ self._params: Dict[str, Dict[str, Any]] = {}
35
+ self._param_keys: list[str] = []
36
+
37
+ def set_params(self, params: Dict[str, Dict[str, Any]]) -> None:
38
+ self._params = params
39
+ self._param_keys = sorted(params.keys())
40
+ self._refresh_list(self._param_keys)
41
+ self._set_text("Select a parameter file to view contents.")
42
+
43
+ def clear(self) -> None:
44
+ self._params = {}
45
+ self._param_keys = []
46
+ self._refresh_list([])
47
+ self._set_text("No parameter data.")
48
+
49
+ def _refresh_list(self, items: Iterable[str]) -> None:
50
+ self._listbox.delete(0, tk.END)
51
+ for item in items:
52
+ self._listbox.insert(tk.END, item)
53
+
54
+ def _set_text(self, text: str) -> None:
55
+ self._text.configure(state=tk.NORMAL)
56
+ self._text.delete("1.0", tk.END)
57
+ self._text.insert(tk.END, text)
58
+ self._text.configure(state=tk.DISABLED)
59
+
60
+ def _on_search(self, *_: object) -> None:
61
+ query = self._search_var.get().strip().lower()
62
+ if not query:
63
+ self._refresh_list(self._param_keys)
64
+ return
65
+ filtered = [key for key in self._param_keys if query in key.lower()]
66
+ self._refresh_list(filtered)
67
+
68
+ def _on_select(self, *_: object) -> None:
69
+ selection = self._listbox.curselection()
70
+ if not selection:
71
+ return
72
+ key = self._listbox.get(int(selection[0]))
73
+ data = self._params.get(key)
74
+ if not data:
75
+ self._set_text("No data.")
76
+ return
77
+ lines = [f"[{key}]"]
78
+ for k in sorted(data.keys()):
79
+ lines.append(f"{k} = {data[k]}")
80
+ self._set_text("\n".join(lines))
@@ -0,0 +1,340 @@
1
+ from __future__ import annotations
2
+
3
+ import tkinter as tk
4
+ from tkinter import ttk
5
+ from typing import Callable, Dict, Optional, Tuple
6
+
7
+ import numpy as np
8
+ from PIL import Image, ImageTk
9
+
10
+
11
+ ClickCallback = Callable[[str, int, int], None]
12
+ ZoomCallback = Callable[[int], None]
13
+
14
+
15
+ class OrthogonalCanvas(ttk.Frame):
16
+ def __init__(self, parent: tk.Widget) -> None:
17
+ super().__init__(parent)
18
+ self.columnconfigure(0, weight=1)
19
+ self.columnconfigure(1, weight=1)
20
+ self.columnconfigure(2, weight=1)
21
+ self.rowconfigure(0, weight=1)
22
+
23
+ self._canvas_xz = tk.Canvas(self, background="#111111", highlightthickness=0)
24
+ self._canvas_xy = tk.Canvas(self, background="#111111", highlightthickness=0)
25
+ self._canvas_zy = tk.Canvas(self, background="#111111", highlightthickness=0)
26
+
27
+ self._canvas_xz.grid(row=0, column=0, sticky="nsew", padx=(0, 4))
28
+ self._canvas_xy.grid(row=0, column=1, sticky="nsew", padx=(0, 4))
29
+ self._canvas_zy.grid(row=0, column=2, sticky="nsew")
30
+
31
+ self._canvas_map = {"xz": self._canvas_xz, "xy": self._canvas_xy, "zy": self._canvas_zy}
32
+ self._canvas_image_id: Dict[str, Optional[int]] = {"zy": None, "xy": None, "xz": None}
33
+ self._canvas_text_id: Dict[str, Optional[int]] = {"zy": None, "xy": None, "xz": None}
34
+ self._tk_images: Dict[str, Optional[ImageTk.PhotoImage]] = {"zy": None, "xy": None, "xz": None}
35
+ self._markers: Dict[str, list[int]] = {"zy": [], "xy": [], "xz": []}
36
+ self._boxes: Dict[str, list[int]] = {"zy": [], "xy": [], "xz": []}
37
+ self._click_callback: Optional[ClickCallback] = None
38
+ self._zoom_callback: Optional[ZoomCallback] = None
39
+
40
+ self._last_views: Optional[Dict[str, Tuple[np.ndarray, Tuple[float, float]]]] = None
41
+ self._last_titles: Optional[Dict[str, str]] = None
42
+ self._last_crosshair: Optional[Dict[str, Tuple[int, int]]] = None
43
+ self._last_show_crosshair: bool = False
44
+ self._last_message: Optional[str] = None
45
+ self._last_is_error = False
46
+ self._render_state: Dict[str, Tuple[int, int, int, int, int, int]] = {}
47
+
48
+ for key, canvas in self._canvas_map.items():
49
+ canvas.bind("<Configure>", self._on_resize)
50
+ canvas.bind("<Button-1>", lambda event, view=key: self._on_click(view, event))
51
+ canvas.bind("<MouseWheel>", self._on_mousewheel)
52
+ canvas.bind("<Button-4>", self._on_mousewheel)
53
+ canvas.bind("<Button-5>", self._on_mousewheel)
54
+
55
+ def set_click_callback(self, callback: Optional[ClickCallback]) -> None:
56
+ self._click_callback = callback
57
+
58
+ def set_zoom_callback(self, callback: Optional[ZoomCallback]) -> None:
59
+ self._zoom_callback = callback
60
+
61
+ def clear_markers(self) -> None:
62
+ for key, canvas in self._canvas_map.items():
63
+ for marker_id in self._markers[key]:
64
+ try:
65
+ canvas.delete(marker_id)
66
+ except Exception:
67
+ pass
68
+ self._markers[key] = []
69
+
70
+ def clear_boxes(self) -> None:
71
+ for key, canvas in self._canvas_map.items():
72
+ for box_id in self._boxes[key]:
73
+ try:
74
+ canvas.delete(box_id)
75
+ except Exception:
76
+ pass
77
+ self._boxes[key] = []
78
+
79
+ def add_marker(self, view: str, row: int, col: int, color: str) -> None:
80
+ state = self._render_state.get(view)
81
+ if state is None:
82
+ return
83
+ img_h, img_w, offset_x, offset_y, target_w, target_h = state
84
+ if img_h <= 0 or img_w <= 0:
85
+ return
86
+ display_row = img_h - 1 - row
87
+ x = offset_x + (col + 0.5) * target_w / img_w
88
+ y = offset_y + (display_row + 0.5) * target_h / img_h
89
+ r = 4
90
+ canvas = self._canvas_map[view]
91
+ marker_id = canvas.create_oval(x - r, y - r, x + r, y + r, outline=color, width=2)
92
+ self._markers[view].append(marker_id)
93
+
94
+ def add_box(self, view: str, row0: int, col0: int, row1: int, col1: int, *, color: str, width: int = 2) -> None:
95
+ state = self._render_state.get(view)
96
+ if state is None:
97
+ return
98
+ img_h, img_w, offset_x, offset_y, target_w, target_h = state
99
+ if img_h <= 0 or img_w <= 0:
100
+ return
101
+ r0 = max(0, min(int(row0), img_h - 1))
102
+ r1 = max(0, min(int(row1), img_h - 1))
103
+ c0 = max(0, min(int(col0), img_w - 1))
104
+ c1 = max(0, min(int(col1), img_w - 1))
105
+ row_min, row_max = sorted((r0, r1))
106
+ col_min, col_max = sorted((c0, c1))
107
+ disp_row_min = img_h - 1 - row_max
108
+ disp_row_max = img_h - 1 - row_min
109
+ x0 = offset_x + col_min * target_w / img_w
110
+ x1 = offset_x + (col_max + 1) * target_w / img_w
111
+ y0 = offset_y + disp_row_min * target_h / img_h
112
+ y1 = offset_y + (disp_row_max + 1) * target_h / img_h
113
+ canvas = self._canvas_map[view]
114
+ box_id = canvas.create_rectangle(x0, y0, x1, y1, outline=color, width=width)
115
+ self._boxes[view].append(box_id)
116
+
117
+ def render_views(
118
+ self,
119
+ views: Dict[str, Tuple[np.ndarray, Tuple[float, float]]],
120
+ titles: Dict[str, str],
121
+ *,
122
+ crosshair: Optional[Dict[str, Tuple[int, int]]] = None,
123
+ show_crosshair: bool = False,
124
+ ) -> None:
125
+ self._last_views = views
126
+ self._last_titles = titles
127
+ self._last_crosshair = crosshair
128
+ self._last_show_crosshair = bool(show_crosshair)
129
+ self._last_message = None
130
+ self._last_is_error = False
131
+ self._render_all_views()
132
+
133
+ def show_message(self, message: str, *, is_error: bool = False) -> None:
134
+ self._last_views = None
135
+ self._last_titles = None
136
+ self._last_message = message
137
+ self._last_is_error = is_error
138
+ for canvas in self._canvas_map.values():
139
+ self._render_message(canvas, message, is_error=is_error)
140
+
141
+ def show_message_on(self, view: str, message: str, *, is_error: bool = False) -> None:
142
+ self._last_views = None
143
+ self._last_titles = None
144
+ self._last_message = message
145
+ self._last_is_error = is_error
146
+ for key, canvas in self._canvas_map.items():
147
+ if key == view:
148
+ self._render_message(canvas, message, is_error=is_error)
149
+ else:
150
+ canvas.delete("all")
151
+
152
+ def _render_message(self, canvas: tk.Canvas, message: str, *, is_error: bool) -> None:
153
+ canvas.delete("all")
154
+ width = max(canvas.winfo_width(), 1)
155
+ height = max(canvas.winfo_height(), 1)
156
+ if is_error:
157
+ canvas.create_line(10, 10, width - 10, height - 10, fill="#cc3333", width=3)
158
+ canvas.create_line(10, height - 10, width - 10, 10, fill="#cc3333", width=3)
159
+ color = "#cc3333"
160
+ else:
161
+ color = "#dddddd"
162
+ canvas.create_text(
163
+ 10,
164
+ 10,
165
+ anchor="nw",
166
+ fill=color,
167
+ text=message,
168
+ font=("TkDefaultFont", 10, "bold"),
169
+ )
170
+
171
+ def _render_canvas(
172
+ self,
173
+ canvas: tk.Canvas,
174
+ key: str,
175
+ img: np.ndarray,
176
+ res: Tuple[float, float],
177
+ title: str,
178
+ *,
179
+ shared_scale: Optional[float] = None,
180
+ ) -> None:
181
+ canvas.delete("all")
182
+ self._markers[key] = []
183
+ self._boxes[key] = []
184
+ img = np.asarray(img)
185
+ if np.iscomplexobj(img):
186
+ img = np.abs(img)
187
+ else:
188
+ try:
189
+ img = img.astype(float, copy=False)
190
+ except Exception:
191
+ img = img.astype(np.float32, copy=False)
192
+ vmin, vmax = np.nanpercentile(img, (1, 99))
193
+ if np.isclose(vmin, vmax):
194
+ vmax = vmin + 1.0
195
+ img_norm = np.clip((img - vmin) / (vmax - vmin), 0.0, 1.0)
196
+ img_uint8 = (img_norm * 255).astype(np.uint8)
197
+ img_display = np.flipud(img_uint8)
198
+
199
+ pil_img = Image.fromarray(img_display, mode="L")
200
+ canvas_w = max(canvas.winfo_width(), 1)
201
+ canvas_h = max(canvas.winfo_height(), 1)
202
+ width_mm = float(img.shape[1]) * res[0]
203
+ height_mm = float(img.shape[0]) * res[1]
204
+ if shared_scale is not None and width_mm > 0 and height_mm > 0:
205
+ target_w = max(int(round(width_mm * shared_scale)), 1)
206
+ target_h = max(int(round(height_mm * shared_scale)), 1)
207
+ target_w = min(target_w, canvas_w)
208
+ target_h = min(target_h, canvas_h)
209
+ else:
210
+ if width_mm > 0 and height_mm > 0:
211
+ aspect = width_mm / height_mm
212
+ else:
213
+ aspect = pil_img.width / max(pil_img.height, 1)
214
+ canvas_aspect = canvas_w / max(canvas_h, 1)
215
+ if canvas_aspect >= aspect:
216
+ target_h = canvas_h
217
+ target_w = max(int(target_h * aspect), 1)
218
+ else:
219
+ target_w = canvas_w
220
+ target_h = max(int(target_w / aspect), 1)
221
+
222
+ resampling = getattr(Image, "Resampling", Image)
223
+ resample = getattr(resampling, "NEAREST")
224
+ pil_img = pil_img.resize((target_w, target_h), resample)
225
+
226
+ self._tk_images[key] = ImageTk.PhotoImage(pil_img)
227
+ x = (canvas_w - target_w) // 2
228
+ y = (canvas_h - target_h) // 2
229
+ self._render_state[key] = (img.shape[0], img.shape[1], x, y, target_w, target_h)
230
+ self._canvas_image_id[key] = canvas.create_image(
231
+ x,
232
+ y,
233
+ anchor="nw",
234
+ image=self._tk_images[key],
235
+ )
236
+
237
+ self._canvas_text_id[key] = canvas.create_text(
238
+ 10,
239
+ 10,
240
+ anchor="nw",
241
+ fill="#dddddd",
242
+ text=title,
243
+ font=("TkDefaultFont", 10, "bold"),
244
+ )
245
+
246
+ if self._last_show_crosshair and self._last_crosshair is not None:
247
+ rc = self._last_crosshair.get(key)
248
+ if rc is not None:
249
+ self._draw_crosshair(key=key, row=int(rc[0]), col=int(rc[1]))
250
+
251
+ def _draw_crosshair(self, *, key: str, row: int, col: int) -> None:
252
+ state = self._render_state.get(key)
253
+ if state is None:
254
+ return
255
+ img_h, img_w, offset_x, offset_y, target_w, target_h = state
256
+ if img_h <= 0 or img_w <= 0 or target_w <= 0 or target_h <= 0:
257
+ return
258
+ if row < 0 or col < 0 or row >= img_h or col >= img_w:
259
+ return
260
+
261
+ display_row = img_h - 1 - row
262
+ x = offset_x + (col + 0.5) * target_w / img_w
263
+ y = offset_y + (display_row + 0.5) * target_h / img_h
264
+ x0, x1 = offset_x, offset_x + target_w
265
+ y0, y1 = offset_y, offset_y + target_h
266
+
267
+ dash = (2, 4)
268
+ color = "#ffffff"
269
+ width = 1
270
+ canvas = self._canvas_map[key]
271
+ canvas.create_line(x0, y, x1, y, fill=color, width=width, dash=dash)
272
+ canvas.create_line(x, y0, x, y1, fill=color, width=width, dash=dash)
273
+
274
+ def _on_resize(self, *_: object) -> None:
275
+ if self._last_message is not None:
276
+ for canvas in self._canvas_map.values():
277
+ self._render_message(canvas, self._last_message, is_error=self._last_is_error)
278
+ return
279
+ if self._last_views and self._last_titles:
280
+ self._render_all_views()
281
+
282
+ def _render_all_views(self) -> None:
283
+ if not self._last_views or not self._last_titles:
284
+ return
285
+
286
+ scales = []
287
+ for key, canvas in self._canvas_map.items():
288
+ img, res = self._last_views[key]
289
+ canvas_w = max(canvas.winfo_width(), 1)
290
+ canvas_h = max(canvas.winfo_height(), 1)
291
+ width_mm = float(img.shape[1]) * float(res[0])
292
+ height_mm = float(img.shape[0]) * float(res[1])
293
+ if width_mm <= 0 or height_mm <= 0:
294
+ continue
295
+ scales.append(min(canvas_w / width_mm, canvas_h / height_mm))
296
+ shared_scale = min(scales) if scales else None
297
+
298
+ for key, canvas in self._canvas_map.items():
299
+ img, res = self._last_views[key]
300
+ title = self._last_titles.get(key, "")
301
+ self._render_canvas(canvas, key, img, res, title, shared_scale=shared_scale)
302
+
303
+ def _on_click(self, view: str, event: tk.Event) -> None:
304
+ if self._click_callback is None:
305
+ return
306
+ state = self._render_state.get(view)
307
+ if state is None:
308
+ return
309
+ img_h, img_w, offset_x, offset_y, target_w, target_h = state
310
+ if img_h <= 0 or img_w <= 0:
311
+ return
312
+ x = event.x - offset_x
313
+ y = event.y - offset_y
314
+ if x < 0 or y < 0 or x > target_w or y > target_h:
315
+ return
316
+ col = int(x * img_w / max(target_w, 1))
317
+ display_row = int(y * img_h / max(target_h, 1))
318
+ row = img_h - 1 - display_row
319
+ self._click_callback(view, row, col)
320
+
321
+ def _on_mousewheel(self, event: tk.Event) -> Optional[str]:
322
+ if self._zoom_callback is None:
323
+ return None
324
+ direction = 0
325
+ delta = getattr(event, "delta", 0)
326
+ if isinstance(delta, int) and delta != 0:
327
+ direction = 1 if delta > 0 else -1
328
+ else:
329
+ num = getattr(event, "num", None)
330
+ if num == 4:
331
+ direction = 1
332
+ elif num == 5:
333
+ direction = -1
334
+ if direction != 0:
335
+ try:
336
+ self._zoom_callback(direction)
337
+ except Exception:
338
+ pass
339
+ return "break"
340
+ return None
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, List, Optional
5
+ import logging
6
+
7
+ from brkraw.core import config as config_core
8
+ from brkraw.core.config import resolve_root
9
+
10
+ logger = logging.getLogger("brkraw.viewer")
11
+
12
+
13
+ def _default_registry_columns() -> List[Dict[str, Any]]:
14
+ return [
15
+ {"key": "basename", "title": "Name", "width": 180},
16
+ {"key": "Study.ID", "title": "Study ID", "width": 120},
17
+ {"key": "Study.Date", "title": "Study Date", "width": 120},
18
+ {"key": "Study.Number", "title": "Study Number", "width": 120},
19
+ {"key": "Study.Operator", "title": "Study Operator", "width": 140},
20
+ {"key": "num_scans", "title": "Scans", "width": 70},
21
+ {"key": "path", "title": "Path", "width": 360},
22
+ ]
23
+
24
+
25
+ def default_viewer_config() -> Dict[str, Any]:
26
+ return {
27
+ "cache": {
28
+ "enabled": False,
29
+ "max_items": 10,
30
+ },
31
+ "registry": {
32
+ "path": "viewer/registry.jsonl",
33
+ "columns": _default_registry_columns(),
34
+ },
35
+ }
36
+
37
+
38
+ def _deep_merge(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]:
39
+ merged = dict(base)
40
+ for key, value in updates.items():
41
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
42
+ merged[key] = _deep_merge(merged[key], value)
43
+ else:
44
+ merged[key] = value
45
+ return merged
46
+
47
+
48
+ def _load_config(root: Optional[Path]) -> Dict[str, Any]:
49
+ data = config_core.load_config(root=root)
50
+ if data is None:
51
+ data = config_core.default_config()
52
+ if not isinstance(data, dict):
53
+ raise ValueError("config.yaml must contain a YAML mapping at the top level.")
54
+ return data
55
+
56
+
57
+ def load_viewer_config(root: Optional[Path] = None) -> Dict[str, Any]:
58
+ config = _load_config(root)
59
+ viewer = config.get("viewer", {})
60
+ if not isinstance(viewer, dict):
61
+ viewer = {}
62
+ return _deep_merge(default_viewer_config(), viewer)
63
+
64
+
65
+ def ensure_viewer_config(root: Optional[Path] = None) -> Dict[str, Any]:
66
+ config = _load_config(root)
67
+ viewer = config.get("viewer", {})
68
+ if not isinstance(viewer, dict):
69
+ viewer = {}
70
+ merged = _deep_merge(default_viewer_config(), viewer)
71
+ config["viewer"] = merged
72
+ config_core.write_config(config, root=root)
73
+ return merged
74
+
75
+
76
+ def save_viewer_config(viewer: Dict[str, Any], root: Optional[Path] = None) -> None:
77
+ config = _load_config(root)
78
+ config["viewer"] = viewer
79
+ config_core.write_config(config, root=root)
80
+
81
+
82
+ def registry_path(root: Optional[Path] = None) -> Path:
83
+ viewer = load_viewer_config(root)
84
+ registry = viewer.get("registry", {})
85
+ rel = registry.get("path", "viewer/registry.jsonl")
86
+ if isinstance(rel, str):
87
+ rel_path = Path(rel)
88
+ else:
89
+ rel_path = Path("viewer/registry.jsonl")
90
+ if rel_path.is_absolute():
91
+ return rel_path
92
+ return resolve_root(root) / rel_path
93
+
94
+
95
+ def registry_columns(root: Optional[Path] = None) -> List[Dict[str, Any]]:
96
+ viewer = load_viewer_config(root)
97
+ registry = viewer.get("registry", {})
98
+ columns = registry.get("columns", [])
99
+ if isinstance(columns, list):
100
+ return [col for col in columns if isinstance(col, dict)]
101
+ return _default_registry_columns()
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ from brkraw.core import config as config_core
9
+ from brkraw.core import formatter
10
+
11
+ from .apps.viewer import launch
12
+ from .registry import registry_status, register_paths, unregister_paths, resolve_entry_value
13
+ from .frames.viewer_config import ensure_viewer_config, registry_columns, registry_path
14
+
15
+
16
+ def register(subparsers: argparse._SubParsersAction) -> None: # type: ignore[name-defined]
17
+ parser = subparsers.add_parser(
18
+ "viewer",
19
+ help="Launch the BrkRaw scan viewer GUI.",
20
+ )
21
+ parser.add_argument(
22
+ "path",
23
+ nargs="*",
24
+ help="Path to the Bruker study root directory.",
25
+ )
26
+ parser.add_argument("--scan", type=int, default=None, help="Initial scan id.")
27
+ parser.add_argument("--reco", type=int, default=None, help="Initial reco id.")
28
+ parser.add_argument(
29
+ "--info-spec",
30
+ default=None,
31
+ help="Optional scan info spec YAML path (use instead of the default mapping).",
32
+ )
33
+ parser.set_defaults(func=_run_viewer)
34
+
35
+
36
+ def _run_viewer(args: argparse.Namespace) -> int:
37
+ commands = {"init", "register", "unregister", "list"}
38
+ paths: List[str] = list(args.path or [])
39
+ if paths and paths[0] in commands:
40
+ command = paths.pop(0)
41
+ if command == "init":
42
+ return _cmd_init()
43
+ if command == "register":
44
+ return _cmd_register(paths)
45
+ if command == "unregister":
46
+ return _cmd_unregister(paths)
47
+ if command == "list":
48
+ return _cmd_list()
49
+ return 2
50
+ if len(paths) > 1:
51
+ print("Error: too many paths provided for viewer launch.", flush=True)
52
+ return 2
53
+ path = paths[0] if paths else None
54
+ return launch(
55
+ path=path,
56
+ scan_id=args.scan,
57
+ reco_id=args.reco,
58
+ info_spec=args.info_spec,
59
+ )
60
+
61
+
62
+ def _cmd_init() -> int:
63
+ ensure_viewer_config()
64
+ reg_path = registry_path()
65
+ reg_path.parent.mkdir(parents=True, exist_ok=True)
66
+ if not reg_path.exists():
67
+ reg_path.write_text("", encoding="utf-8")
68
+ print(f"Viewer registry initialized: {reg_path}")
69
+ return 0
70
+
71
+
72
+ def _cmd_register(paths: List[str]) -> int:
73
+ if not paths:
74
+ print("Error: missing path to register.")
75
+ return 2
76
+ targets = [Path(p) for p in paths]
77
+ try:
78
+ entries = register_paths(targets)
79
+ except Exception as exc:
80
+ print(f"Error: failed to register dataset(s): {exc}")
81
+ return 2
82
+ print(f"Registered {len(entries)} dataset(s).")
83
+ return 0
84
+
85
+
86
+ def _cmd_unregister(paths: List[str]) -> int:
87
+ if not paths:
88
+ print("Error: missing path to unregister.")
89
+ return 2
90
+ targets = [Path(p) for p in paths]
91
+ removed = unregister_paths(targets)
92
+ print(f"Removed {removed} dataset(s).")
93
+ return 0
94
+
95
+
96
+ def _cmd_list() -> int:
97
+ logger = logging.getLogger("brkraw.viewer")
98
+ rows = registry_status()
99
+ width = config_core.output_width(root=None)
100
+ columns = [dict(col) for col in registry_columns()]
101
+ if not any(col.get("key") == "missing" for col in columns):
102
+ columns.append({"key": "missing", "title": "Missing", "width": 80})
103
+ visible = [col for col in columns if not col.get("hidden")]
104
+ keys = [col["key"] for col in visible]
105
+ formatted_rows = []
106
+ for entry in rows:
107
+ row: dict[str, object] = {}
108
+ for col in visible:
109
+ key = col["key"]
110
+ value = resolve_entry_value(entry, key)
111
+ if key == "missing" and entry.get("missing"):
112
+ row[key] = {"value": value, "color": "red"}
113
+ else:
114
+ row[key] = value
115
+ formatted_rows.append(row)
116
+ table = formatter.format_table(
117
+ "Viewer Registry",
118
+ tuple(keys),
119
+ formatted_rows,
120
+ width=width,
121
+ title_color="cyan",
122
+ col_widths=formatter.compute_column_widths(tuple(keys), formatted_rows),
123
+ )
124
+ logger.info("%s", table)
125
+ return 0