spatialdata-js-util 0.1.0__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.
Files changed (40) hide show
  1. spatialdata_js_util/__init__.py +107 -0
  2. spatialdata_js_util/cli.py +803 -0
  3. spatialdata_js_util/codecs/__init__.py +67 -0
  4. spatialdata_js_util/codecs/backends.py +250 -0
  5. spatialdata_js_util/codecs/chunks.py +40 -0
  6. spatialdata_js_util/codecs/encoding.py +154 -0
  7. spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
  8. spatialdata_js_util/codecs/names.py +26 -0
  9. spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  10. spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
  11. spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  12. spatialdata_js_util/codecs/vendor/__init__.py +1 -0
  13. spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
  14. spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
  15. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
  16. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  17. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
  18. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  19. spatialdata_js_util/codecs/zarr_codec.py +149 -0
  20. spatialdata_js_util/errors.py +5 -0
  21. spatialdata_js_util/images.py +849 -0
  22. spatialdata_js_util/index_permutations.py +311 -0
  23. spatialdata_js_util/points.py +445 -0
  24. spatialdata_js_util/points_zarr.py +799 -0
  25. spatialdata_js_util/provenance.py +17 -0
  26. spatialdata_js_util/pyramids.py +318 -0
  27. spatialdata_js_util/runners.py +458 -0
  28. spatialdata_js_util/store.py +305 -0
  29. spatialdata_js_util/tables.py +310 -0
  30. spatialdata_js_util/tui/__init__.py +0 -0
  31. spatialdata_js_util/tui/app.py +112 -0
  32. spatialdata_js_util/tui/models.py +45 -0
  33. spatialdata_js_util/tui/screens.py +1255 -0
  34. spatialdata_js_util/tui/widgets.py +50 -0
  35. spatialdata_js_util/verify.py +338 -0
  36. spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
  37. spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
  38. spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
  39. spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
  40. spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1255 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import traceback
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from textual import getters, work
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.events import ScreenResume
13
+ from textual.containers import Horizontal, Vertical, VerticalScroll
14
+ from textual.screen import Screen
15
+ from textual.widgets import (
16
+ Button,
17
+ Checkbox,
18
+ DataTable,
19
+ Footer,
20
+ Input,
21
+ Label,
22
+ ListItem,
23
+ ListView,
24
+ RichLog,
25
+ Select,
26
+ Static,
27
+ )
28
+
29
+ from ..codecs import CODEC_HTJ2K_OPENJPH, CODEC_JPEG2K
30
+ from ..pyramids import DEFAULT_MIN_SIZE as DEFAULT_PYRAMID_MIN_SIZE
31
+ from ..errors import WriterCommandError
32
+ from ..index_permutations import DEFAULT_CONDITIONS
33
+ from ..runners import (
34
+ resolve_morton_from_zarr_output,
35
+ run_list_points,
36
+ run_list_tables,
37
+ run_morton_points,
38
+ run_morton_points_from_zarr,
39
+ run_multiscale_points,
40
+ run_tables_to_csc,
41
+ run_write_index_permutations,
42
+ )
43
+ from ..verify import (
44
+ VerifyCheck,
45
+ all_passed,
46
+ verify_index_permutations_manifest,
47
+ verify_morton_parquet,
48
+ verify_multiscale_parquet,
49
+ )
50
+ from ..store import list_points_keys, read_points_element_attrs
51
+ from .app import WriterApp
52
+ from .widgets import SafeHeader
53
+ from .models import CommandId, TaskSpec
54
+
55
+
56
+ def _positive_int(value: str, default: int) -> int:
57
+ stripped = value.strip()
58
+ if not stripped:
59
+ return default
60
+ parsed = int(stripped)
61
+ if parsed <= 0:
62
+ raise ValueError("value must be positive")
63
+ return parsed
64
+
65
+
66
+ class WriterScreen(Screen[None]):
67
+ app = getters.app(WriterApp)
68
+
69
+
70
+ class InputFormScreen(WriterScreen):
71
+ """Form screen with Enter-to-advance/submit and Escape-to-back."""
72
+
73
+ INPUT_ORDER: tuple[str, ...] = ()
74
+ PRIMARY_BUTTON_ID: str = "run"
75
+
76
+ BINDINGS = [
77
+ Binding("escape", "go_back", "Back"),
78
+ ]
79
+
80
+ def on_mount(self) -> None:
81
+ if self.INPUT_ORDER:
82
+ self.query_one(f"#{self.INPUT_ORDER[0]}", Input).focus()
83
+
84
+ def on_input_submitted(self, event: Input.Submitted) -> None:
85
+ input_id = event.input.id
86
+ if input_id is None:
87
+ self._press_primary()
88
+ return
89
+ if not self.INPUT_ORDER or input_id not in self.INPUT_ORDER:
90
+ self._press_primary()
91
+ return
92
+ if input_id == self.INPUT_ORDER[-1]:
93
+ self._press_primary()
94
+ return
95
+ next_index = self.INPUT_ORDER.index(input_id) + 1
96
+ self.query_one(f"#{self.INPUT_ORDER[next_index]}", Input).focus()
97
+
98
+ def action_go_back(self) -> None:
99
+ self.app.pop_screen()
100
+
101
+ def _press_primary(self) -> None:
102
+ self.query_one(f"#{self.PRIMARY_BUTTON_ID}", Button).press()
103
+
104
+
105
+ class HomeScreen(WriterScreen):
106
+ BINDINGS = [("q", "quit", "Quit")]
107
+
108
+ def on_mount(self) -> None:
109
+ self.query_one("#command-list", ListView).focus()
110
+
111
+ def on_screen_resume(self, event: ScreenResume) -> None:
112
+ self.refresh()
113
+ self.query_one("#command-list", ListView).focus()
114
+
115
+ def compose(self) -> ComposeResult:
116
+ yield SafeHeader()
117
+ yield Static(
118
+ "spatialdata-js-util — pick a command.",
119
+ id="home-title",
120
+ )
121
+ yield ListView(
122
+ ListItem(Label("IMAGES Recompress rasters (JPEG 2000 / HTJ2K)"), id="cmd-recompress"),
123
+ ListItem(Label("POINTS List Points elements in a Zarr store"), id="cmd-list-points"),
124
+ ListItem(Label("POINTS Morton-sort Points from Zarr"), id="cmd-morton-from-zarr"),
125
+ ListItem(Label("POINTS Morton-sort CSV/Parquet file"), id="cmd-morton-points"),
126
+ ListItem(Label("POINTS Write multiscale Points Parquet"), id="cmd-multiscale-points"),
127
+ ListItem(
128
+ Label("POINTS Write index permutations derivative store"),
129
+ id="cmd-index-permutations",
130
+ ),
131
+ ListItem(Label("TABLES List table elements in a Zarr store"), id="cmd-list-tables"),
132
+ ListItem(Label("TABLES Convert table matrices to CSC"), id="cmd-tables-to-csc"),
133
+ ListItem(Label("CODECS Show HTJ2K backend availability"), id="cmd-codecs-info"),
134
+ id="command-list",
135
+ )
136
+ yield Footer()
137
+
138
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
139
+ item_id = event.item.id or ""
140
+ if item_id == "cmd-recompress":
141
+ self.app.push_screen(RecompressImagesScreen())
142
+ elif item_id == "cmd-list-points":
143
+ self._start_zarr_command(CommandId.LIST_POINTS)
144
+ elif item_id == "cmd-morton-from-zarr":
145
+ self._start_zarr_command(CommandId.MORTON_FROM_ZARR)
146
+ elif item_id == "cmd-morton-points":
147
+ self.app.push_screen(MortonFileScreen())
148
+ elif item_id == "cmd-multiscale-points":
149
+ self.app.push_screen(MultiscaleScreen())
150
+ elif item_id == "cmd-index-permutations":
151
+ self.app.push_screen(IndexPermutationsScreen())
152
+ elif item_id == "cmd-list-tables":
153
+ self._start_list_tables()
154
+ elif item_id == "cmd-tables-to-csc":
155
+ self.app.push_screen(TablesToCscScreen())
156
+ elif item_id == "cmd-codecs-info":
157
+ self.app.push_screen(RunScreen(codecs_info_task()))
158
+
159
+ def _start_list_tables(self) -> None:
160
+ zarr = self.app.context.zarr_path
161
+ if not zarr:
162
+ self.app.push_screen(ZarrPathScreen(CommandId.LIST_TABLES))
163
+ return
164
+ self.app.push_screen(RunScreen(list_tables_task(zarr)))
165
+
166
+ def _start_zarr_command(self, command: CommandId) -> None:
167
+ if command == CommandId.LIST_POINTS:
168
+ if self.app.context.zarr_path:
169
+ self._run_list_points(self.app.context.zarr_path)
170
+ else:
171
+ self.app.push_screen(ZarrPathScreen(command))
172
+ return
173
+ if self.app.context.zarr_path:
174
+ self.app.push_screen(PointsKeyScreen(command))
175
+ else:
176
+ self.app.push_screen(ZarrPathScreen(command))
177
+
178
+ def _run_list_points(self, zarr: str) -> None:
179
+ def runner() -> dict[str, Any]:
180
+ return run_list_points(zarr)
181
+
182
+ self.app.push_screen(
183
+ RunScreen(
184
+ TaskSpec(
185
+ command=CommandId.LIST_POINTS,
186
+ title="List Points",
187
+ runner=runner,
188
+ verify_kind="none",
189
+ )
190
+ )
191
+ )
192
+
193
+
194
+ class ZarrPathScreen(InputFormScreen):
195
+ INPUT_ORDER = ("zarr-path",)
196
+ PRIMARY_BUTTON_ID = "continue"
197
+
198
+ def __init__(self, command: CommandId) -> None:
199
+ super().__init__()
200
+ self.command = command
201
+
202
+ def compose(self) -> ComposeResult:
203
+ yield SafeHeader()
204
+ yield Static("SpatialData Zarr store path", classes="screen-title")
205
+ yield Input(placeholder="/path/to/store.zarr", id="zarr-path")
206
+ with Horizontal():
207
+ yield Button("Continue", variant="primary", id="continue")
208
+ yield Button("Back", id="back")
209
+ yield Footer()
210
+
211
+ def on_button_pressed(self, event: Button.Pressed) -> None:
212
+ if event.button.id == "back":
213
+ self.action_go_back()
214
+ return
215
+ self._continue()
216
+
217
+ def _continue(self) -> None:
218
+ path = self.query_one("#zarr-path", Input).value.strip()
219
+ if not path:
220
+ self.notify("Enter a Zarr store path.", severity="error")
221
+ return
222
+ resolved = Path(path)
223
+ if not resolved.is_dir():
224
+ self.notify(f"Not a directory: {path}", severity="error")
225
+ return
226
+ self.app.context.zarr_path = str(resolved)
227
+ if self.command == CommandId.LIST_POINTS:
228
+ self._run_list_points(path)
229
+ return
230
+ if self.command == CommandId.LIST_TABLES:
231
+ self.app.push_screen(RunScreen(list_tables_task(path)))
232
+ return
233
+ self.app.push_screen(PointsKeyScreen(self.command))
234
+
235
+ def _run_list_points(self, zarr: str) -> None:
236
+ def runner() -> dict[str, Any]:
237
+ return run_list_points(zarr)
238
+
239
+ self.app.push_screen(
240
+ RunScreen(
241
+ TaskSpec(
242
+ command=CommandId.LIST_POINTS,
243
+ title="List Points",
244
+ runner=runner,
245
+ verify_kind="none",
246
+ )
247
+ )
248
+ )
249
+
250
+
251
+ class PointsKeyScreen(WriterScreen):
252
+ BINDINGS = [
253
+ Binding("escape", "go_back", "Back"),
254
+ ]
255
+
256
+ def __init__(self, command: CommandId) -> None:
257
+ super().__init__()
258
+ self.command = command
259
+
260
+ def compose(self) -> ComposeResult:
261
+ yield SafeHeader()
262
+ yield Static("Select Points element", classes="screen-title")
263
+ yield ListView(id="points-key-list")
264
+ with Horizontal():
265
+ yield Button("Continue", variant="primary", id="continue")
266
+ yield Button("Back", id="back")
267
+ yield Footer()
268
+
269
+ def on_mount(self) -> None:
270
+ zarr = self.app.context.zarr_path
271
+ list_view = self.query_one("#points-key-list", ListView)
272
+ if not zarr:
273
+ return
274
+ keys = list_points_keys(zarr)
275
+ if not keys:
276
+ list_view.mount(Static("No Points elements found."))
277
+ return
278
+ for key in keys:
279
+ list_view.mount(ListItem(Label(key), id=f"key-{key}"))
280
+ if len(keys) == 1:
281
+ self.app.context.points_key = keys[0]
282
+ list_view.index = 0
283
+ list_view.focus()
284
+
285
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
286
+ item_id = event.item.id or ""
287
+ if item_id.startswith("key-"):
288
+ self.app.context.points_key = item_id.removeprefix("key-")
289
+ self._continue()
290
+
291
+ def action_go_back(self) -> None:
292
+ self.app.pop_screen()
293
+
294
+ def on_button_pressed(self, event: Button.Pressed) -> None:
295
+ if event.button.id == "back":
296
+ self.action_go_back()
297
+ return
298
+ self._continue()
299
+
300
+ def _continue(self) -> None:
301
+ list_view = self.query_one("#points-key-list", ListView)
302
+ if list_view.index is None:
303
+ self.notify("Select a Points element.", severity="error")
304
+ return
305
+ item = list_view.children[list_view.index]
306
+ item_id = item.id or ""
307
+ if not item_id.startswith("key-"):
308
+ self.notify("Select a Points element.", severity="error")
309
+ return
310
+ self.app.context.points_key = item_id.removeprefix("key-")
311
+ if self.command == CommandId.MORTON_FROM_ZARR:
312
+ self.app.push_screen(MortonFromZarrScreen())
313
+ elif self.command == CommandId.INDEX_PERMUTATIONS:
314
+ self.app.push_screen(IndexPermutationsScreen(from_zarr_context=True))
315
+
316
+
317
+ class MortonFromZarrScreen(InputFormScreen):
318
+ INPUT_ORDER = ("feature-key", "row-group-size", "compression", "output-element")
319
+
320
+ def compose(self) -> ComposeResult:
321
+ yield SafeHeader()
322
+ yield Static("Morton-sort Points from Zarr", classes="screen-title")
323
+ with VerticalScroll():
324
+ yield Label("Feature key column (optional)")
325
+ yield Input(placeholder="feature_name", id="feature-key")
326
+ yield Label("Row group size")
327
+ yield Input(value="50000", id="row-group-size")
328
+ yield Label("Compression")
329
+ yield Input(value="zstd", id="compression")
330
+ yield Label("Output Points element name (optional)")
331
+ yield Input(placeholder="leave empty to overwrite selected element", id="output-element")
332
+ yield Checkbox("Write to points.experimental/", id="experimental")
333
+ with Horizontal():
334
+ yield Button("Run", variant="primary", id="run")
335
+ yield Button("Back", id="back")
336
+ yield Footer()
337
+
338
+ def on_mount(self) -> None:
339
+ zarr = self.app.context.zarr_path
340
+ key = self.app.context.points_key
341
+ if not zarr or not key:
342
+ return
343
+ try:
344
+ attrs = read_points_element_attrs(zarr, key)
345
+ feature_key = attrs.get("feature_key")
346
+ if feature_key:
347
+ self.query_one("#feature-key", Input).value = str(feature_key)
348
+ except OSError:
349
+ pass
350
+ super().on_mount()
351
+
352
+ def on_button_pressed(self, event: Button.Pressed) -> None:
353
+ if event.button.id == "back":
354
+ self.action_go_back()
355
+ return
356
+ self._submit()
357
+
358
+ def _submit(self) -> None:
359
+ zarr = self.app.context.zarr_path
360
+ key = self.app.context.points_key
361
+ if not zarr or not key:
362
+ self.notify("Missing Zarr context.", severity="error")
363
+ return
364
+
365
+ feature_key = self.query_one("#feature-key", Input).value.strip() or None
366
+ output_key = self.query_one("#output-element", Input).value.strip() or None
367
+ experimental = self.query_one("#experimental", Checkbox).value
368
+ try:
369
+ row_group_size = _positive_int(
370
+ self.query_one("#row-group-size", Input).value, 50_000
371
+ )
372
+ except ValueError as exc:
373
+ self.notify(str(exc), severity="error")
374
+ return
375
+ compression = self.query_one("#compression", Input).value.strip() or "zstd"
376
+
377
+ try:
378
+ output_spec = resolve_morton_from_zarr_output(
379
+ Path(zarr),
380
+ key,
381
+ output_points_key=output_key,
382
+ experimental=experimental,
383
+ )
384
+ except WriterCommandError as exc:
385
+ self.notify(str(exc), severity="error")
386
+ return
387
+
388
+ target_exists = output_spec.output_parquet.exists()
389
+ if (
390
+ output_spec.collection == "points"
391
+ and output_spec.output_points_key is not None
392
+ ):
393
+ target_exists = target_exists or (
394
+ Path(zarr) / "points" / output_spec.output_points_key
395
+ ).exists()
396
+ requires_confirm = output_spec.in_place or target_exists
397
+
398
+ def runner() -> dict[str, Any]:
399
+ return run_morton_points_from_zarr(
400
+ zarr,
401
+ points_key=key,
402
+ experimental=experimental,
403
+ output_points_key=output_key,
404
+ feature_key=feature_key,
405
+ overwrite=requires_confirm,
406
+ row_group_size=row_group_size,
407
+ compression=compression,
408
+ )
409
+
410
+ if output_spec.in_place:
411
+ confirm_message = (
412
+ f"Overwrite selected Points element:\npoints/{key}\n\n"
413
+ f"Parquet path:\n{output_spec.output_parquet}\n\nProceed?"
414
+ )
415
+ elif target_exists and output_spec.collection == "points":
416
+ confirm_message = (
417
+ f"Overwrite existing Points element:\n"
418
+ f"points/{output_spec.output_points_key}\n\n"
419
+ f"Parquet path:\n{output_spec.output_parquet}\n\nProceed?"
420
+ )
421
+ elif target_exists and output_spec.collection == "points.experimental":
422
+ confirm_message = (
423
+ f"Overwrite existing experimental Points artifact:\n"
424
+ f"points.experimental/{output_spec.output_points_key}\n\n"
425
+ f"Parquet path:\n{output_spec.output_parquet}\n\nProceed?"
426
+ )
427
+ else:
428
+ confirm_message = ""
429
+
430
+ task = TaskSpec(
431
+ command=CommandId.MORTON_FROM_ZARR,
432
+ title="Morton-sort from Zarr",
433
+ runner=runner,
434
+ verify_kind="morton",
435
+ verify_paths=[output_spec.output_parquet],
436
+ requires_confirm=requires_confirm,
437
+ confirm_message=confirm_message,
438
+ )
439
+ self._launch_task(task)
440
+
441
+ def _launch_task(self, task_spec: TaskSpec) -> None:
442
+ if task_spec.requires_confirm:
443
+ self.app.push_screen(ConfirmScreen(task_spec))
444
+ else:
445
+ self.app.push_screen(RunScreen(task_spec))
446
+
447
+
448
+ class MortonFileScreen(InputFormScreen):
449
+ INPUT_ORDER = (
450
+ "input-path",
451
+ "output-path",
452
+ "feature-key",
453
+ "row-group-size",
454
+ "compression",
455
+ )
456
+
457
+ def compose(self) -> ComposeResult:
458
+ yield SafeHeader()
459
+ yield Static("Morton-sort CSV/Parquet", classes="screen-title")
460
+ with VerticalScroll():
461
+ yield Label("Input path")
462
+ yield Input(placeholder="input.csv or input.parquet", id="input-path")
463
+ yield Label("Output path")
464
+ yield Input(placeholder="output.parquet", id="output-path")
465
+ yield Label("Feature key column (optional)")
466
+ yield Input(placeholder="feature_name", id="feature-key")
467
+ yield Label("Row group size")
468
+ yield Input(value="50000", id="row-group-size")
469
+ yield Label("Compression")
470
+ yield Input(value="zstd", id="compression")
471
+ with Horizontal():
472
+ yield Button("Run", variant="primary", id="run")
473
+ yield Button("Back", id="back")
474
+ yield Footer()
475
+
476
+ def on_button_pressed(self, event: Button.Pressed) -> None:
477
+ if event.button.id == "back":
478
+ self.action_go_back()
479
+ return
480
+ self._submit()
481
+
482
+ def _submit(self) -> None:
483
+ input_path = self.query_one("#input-path", Input).value.strip()
484
+ output_path = self.query_one("#output-path", Input).value.strip()
485
+ if not input_path or not output_path:
486
+ self.notify("Input and output paths are required.", severity="error")
487
+ return
488
+ feature_key = self.query_one("#feature-key", Input).value.strip() or None
489
+ try:
490
+ row_group_size = _positive_int(
491
+ self.query_one("#row-group-size", Input).value, 50_000
492
+ )
493
+ except ValueError as exc:
494
+ self.notify(str(exc), severity="error")
495
+ return
496
+ compression = self.query_one("#compression", Input).value.strip() or "zstd"
497
+
498
+ def runner() -> dict[str, Any]:
499
+ return run_morton_points(
500
+ input_path,
501
+ output_path,
502
+ feature_key=feature_key,
503
+ row_group_size=row_group_size,
504
+ compression=compression,
505
+ )
506
+
507
+ self.app.push_screen(
508
+ RunScreen(
509
+ TaskSpec(
510
+ command=CommandId.MORTON_POINTS,
511
+ title="Morton-sort file",
512
+ runner=runner,
513
+ verify_kind="morton",
514
+ verify_paths=[Path(output_path)],
515
+ )
516
+ )
517
+ )
518
+
519
+
520
+ class MultiscaleScreen(InputFormScreen):
521
+ INPUT_ORDER = (
522
+ "input-path",
523
+ "output-path",
524
+ "metadata-json",
525
+ "row-group-size",
526
+ "compression",
527
+ )
528
+
529
+ def compose(self) -> ComposeResult:
530
+ yield SafeHeader()
531
+ yield Static("Multiscale Points Parquet", classes="screen-title")
532
+ with VerticalScroll():
533
+ yield Label("Input path")
534
+ yield Input(placeholder="input.parquet", id="input-path")
535
+ yield Label("Output path")
536
+ yield Input(placeholder="output.parquet", id="output-path")
537
+ yield Label("Metadata JSON path (optional)")
538
+ yield Input(placeholder="metadata.json", id="metadata-json")
539
+ yield Label("Row group size")
540
+ yield Input(value="50000", id="row-group-size")
541
+ yield Label("Compression")
542
+ yield Input(value="zstd", id="compression")
543
+ with Horizontal():
544
+ yield Button("Run", variant="primary", id="run")
545
+ yield Button("Back", id="back")
546
+ yield Footer()
547
+
548
+ def on_button_pressed(self, event: Button.Pressed) -> None:
549
+ if event.button.id == "back":
550
+ self.action_go_back()
551
+ return
552
+ self._submit()
553
+
554
+ def _submit(self) -> None:
555
+ input_path = self.query_one("#input-path", Input).value.strip()
556
+ output_path = self.query_one("#output-path", Input).value.strip()
557
+ if not input_path or not output_path:
558
+ self.notify("Input and output paths are required.", severity="error")
559
+ return
560
+ metadata_json = self.query_one("#metadata-json", Input).value.strip() or None
561
+ try:
562
+ row_group_size = _positive_int(
563
+ self.query_one("#row-group-size", Input).value, 50_000
564
+ )
565
+ except ValueError as exc:
566
+ self.notify(str(exc), severity="error")
567
+ return
568
+ compression = self.query_one("#compression", Input).value.strip() or "zstd"
569
+
570
+ def runner() -> dict[str, Any]:
571
+ return run_multiscale_points(
572
+ input_path,
573
+ output_path,
574
+ metadata_json=metadata_json,
575
+ row_group_size=row_group_size,
576
+ compression=compression,
577
+ )
578
+
579
+ self.app.push_screen(
580
+ RunScreen(
581
+ TaskSpec(
582
+ command=CommandId.MULTISCALE_POINTS,
583
+ title="Multiscale Points",
584
+ runner=runner,
585
+ verify_kind="multiscale",
586
+ verify_paths=[Path(output_path)],
587
+ )
588
+ )
589
+ )
590
+
591
+
592
+ class IndexPermutationsScreen(InputFormScreen):
593
+ INPUT_ORDER = (
594
+ "source-zarr",
595
+ "dest-zarr",
596
+ "points-key",
597
+ "max-rows",
598
+ "row-group-size",
599
+ "compression",
600
+ )
601
+
602
+ def __init__(self, *, from_zarr_context: bool = False) -> None:
603
+ super().__init__()
604
+ self.from_zarr_context = from_zarr_context
605
+
606
+ def compose(self) -> ComposeResult:
607
+ yield SafeHeader()
608
+ yield Static("Write index permutations", classes="screen-title")
609
+ with VerticalScroll():
610
+ yield Label("Source Zarr")
611
+ yield Input(id="source-zarr")
612
+ yield Label("Destination Zarr")
613
+ yield Input(id="dest-zarr")
614
+ yield Label("Points key (optional if single element)")
615
+ yield Input(id="points-key")
616
+ yield Label("Max rows (optional)")
617
+ yield Input(id="max-rows")
618
+ yield Label("Row group size")
619
+ yield Input(value="50000", id="row-group-size")
620
+ yield Label("Compression")
621
+ yield Input(value="zstd", id="compression")
622
+ yield Checkbox("Overwrite destination if it exists", id="overwrite")
623
+ yield Static("Conditions (default: all)", classes="section-label")
624
+ with Vertical(id="conditions"):
625
+ for condition in DEFAULT_CONDITIONS:
626
+ yield Checkbox(condition.id, value=True, id=f"cond-{condition.id}")
627
+ with Horizontal():
628
+ yield Button("Run", variant="primary", id="run")
629
+ yield Button("Back", id="back")
630
+ yield Footer()
631
+
632
+ def on_mount(self) -> None:
633
+ if self.app.context.zarr_path:
634
+ self.query_one("#source-zarr", Input).value = self.app.context.zarr_path
635
+ if self.from_zarr_context and self.app.context.points_key:
636
+ self.query_one("#points-key", Input).value = self.app.context.points_key
637
+ super().on_mount()
638
+
639
+ def on_button_pressed(self, event: Button.Pressed) -> None:
640
+ if event.button.id == "back":
641
+ self.action_go_back()
642
+ return
643
+ self._submit()
644
+
645
+ def _submit(self) -> None:
646
+ source = self.query_one("#source-zarr", Input).value.strip()
647
+ dest = self.query_one("#dest-zarr", Input).value.strip()
648
+ if not source or not dest:
649
+ self.notify("Source and destination Zarr paths are required.", severity="error")
650
+ return
651
+ points_key = self.query_one("#points-key", Input).value.strip() or None
652
+ max_rows_text = self.query_one("#max-rows", Input).value.strip()
653
+ max_rows = None
654
+ if max_rows_text:
655
+ try:
656
+ max_rows = _positive_int(max_rows_text, 0)
657
+ except ValueError as exc:
658
+ self.notify(str(exc), severity="error")
659
+ return
660
+ try:
661
+ row_group_size = _positive_int(
662
+ self.query_one("#row-group-size", Input).value, 50_000
663
+ )
664
+ except ValueError as exc:
665
+ self.notify(str(exc), severity="error")
666
+ return
667
+ compression = self.query_one("#compression", Input).value.strip() or "zstd"
668
+ overwrite = self.query_one("#overwrite", Checkbox).value
669
+ selected = [
670
+ condition.id
671
+ for condition in DEFAULT_CONDITIONS
672
+ if self.query_one(f"#cond-{condition.id}", Checkbox).value
673
+ ]
674
+ if not selected:
675
+ self.notify("Select at least one condition.", severity="error")
676
+ return
677
+ all_selected = len(selected) == len(DEFAULT_CONDITIONS)
678
+ condition_ids = None if all_selected else selected
679
+
680
+ def runner() -> dict[str, Any]:
681
+ return run_write_index_permutations(
682
+ source,
683
+ dest,
684
+ points_key=points_key,
685
+ max_rows=max_rows,
686
+ condition_ids=condition_ids,
687
+ overwrite=overwrite,
688
+ row_group_size=row_group_size,
689
+ compression=compression,
690
+ )
691
+
692
+ self.app.push_screen(
693
+ RunScreen(
694
+ TaskSpec(
695
+ command=CommandId.INDEX_PERMUTATIONS,
696
+ title="Index permutations",
697
+ runner=runner,
698
+ verify_kind="manifest",
699
+ verify_paths=[Path(dest)],
700
+ )
701
+ )
702
+ )
703
+
704
+
705
+ def list_tables_task(zarr: str) -> TaskSpec:
706
+ def runner() -> dict[str, Any]:
707
+ return run_list_tables(zarr)
708
+
709
+ return TaskSpec(
710
+ command=CommandId.LIST_TABLES,
711
+ title="List tables",
712
+ runner=runner,
713
+ )
714
+
715
+
716
+ def codecs_info_task() -> TaskSpec:
717
+ def runner() -> dict[str, Any]:
718
+ from ..codecs.backends import backend_report
719
+
720
+ return backend_report()
721
+
722
+ return TaskSpec(
723
+ command=CommandId.CODECS_INFO,
724
+ title="HTJ2K backends",
725
+ runner=runner,
726
+ )
727
+
728
+
729
+ class RecompressImagesScreen(InputFormScreen):
730
+ """Recompress image rasters — the `images recompress` command."""
731
+
732
+ INPUT_ORDER = (
733
+ "source",
734
+ "dest",
735
+ "image-key",
736
+ "quality",
737
+ "chunks",
738
+ "workers",
739
+ "pyramid-levels",
740
+ "pyramid-downscale",
741
+ "pyramid-min-size",
742
+ )
743
+
744
+ def compose(self) -> ComposeResult:
745
+ yield SafeHeader()
746
+ yield Static("Recompress images", classes="screen-title")
747
+ with VerticalScroll():
748
+ yield Label("Source store")
749
+ yield Input(
750
+ value=self.app.context.zarr_path or "",
751
+ placeholder="/path/to/input.zarr",
752
+ id="source",
753
+ )
754
+ yield Label("Destination store")
755
+ yield Input(placeholder="/path/to/output.zarr", id="dest")
756
+ yield Label("Image key (blank = every image)")
757
+ yield Input(placeholder="morphology_focus", id="image-key")
758
+ yield Label("Codec")
759
+ yield Select(
760
+ [
761
+ ("HTJ2K (experimental.openjph_htj2k)", CODEC_HTJ2K_OPENJPH),
762
+ ("JPEG 2000 (imagecodecs_jpeg2k)", CODEC_JPEG2K),
763
+ ],
764
+ value=CODEC_HTJ2K_OPENJPH,
765
+ allow_blank=False,
766
+ id="codec",
767
+ )
768
+ yield Label("Preset")
769
+ yield Select(
770
+ [("lossless", "lossless"), ("balanced", "balanced"), ("small", "small")],
771
+ value="lossless",
772
+ allow_blank=False,
773
+ id="preset",
774
+ )
775
+ yield Label(
776
+ "HTJ2K quality — quantization step relative to the dtype's full range, "
777
+ "lower = better (blank = use preset). Below one input LSB "
778
+ "(1/256 for 8-bit, 1/65536 for 16-bit) it encodes larger than lossless."
779
+ )
780
+ yield Input(placeholder="0.0002", id="quality")
781
+ yield Label("Chunks")
782
+ yield Input(value="auto", placeholder="auto, or one integer per axis", id="chunks")
783
+ yield Label("Workers")
784
+ yield Input(value=str(os.cpu_count() or 1), id="workers")
785
+ yield Checkbox("Write siblings instead of replacing images", id="sibling")
786
+ yield Checkbox("Overwrite destination if it exists", id="overwrite")
787
+ yield Static("Multiscale pyramid", classes="section-label")
788
+ yield Checkbox(
789
+ "Build a pyramid for images that have only one resolution level",
790
+ id="pyramid",
791
+ )
792
+ yield Label("Levels ('auto' halves until the largest axis fits the size below)")
793
+ yield Input(value="auto", id="pyramid-levels")
794
+ yield Label("Downscale between levels")
795
+ yield Input(value="2", id="pyramid-downscale")
796
+ yield Label("Auto stops at this size (px)")
797
+ yield Input(value=str(DEFAULT_PYRAMID_MIN_SIZE), id="pyramid-min-size")
798
+ yield Checkbox("Rebuild images that already have a pyramid", id="pyramid-force")
799
+ with Horizontal():
800
+ yield Button("Run", variant="primary", id="run")
801
+ yield Button("Back", id="back")
802
+ yield Footer()
803
+
804
+ def on_button_pressed(self, event: Button.Pressed) -> None:
805
+ if event.button.id == "back":
806
+ self.action_go_back()
807
+ return
808
+ self._submit()
809
+
810
+ def _submit(self) -> None:
811
+ source = self.query_one("#source", Input).value.strip()
812
+ dest = self.query_one("#dest", Input).value.strip()
813
+ if not source or not dest:
814
+ self.notify("Source and destination are required.", severity="error")
815
+ return
816
+ if not Path(source).is_dir():
817
+ self.notify(f"Not a directory: {source}", severity="error")
818
+ return
819
+
820
+ codec = str(self.query_one("#codec", Select).value)
821
+ preset = str(self.query_one("#preset", Select).value)
822
+ quality_raw = self.query_one("#quality", Input).value.strip()
823
+ quality: float | None = None
824
+ if quality_raw:
825
+ if codec != CODEC_HTJ2K_OPENJPH:
826
+ self.notify("Quality applies to HTJ2K only.", severity="error")
827
+ return
828
+ try:
829
+ quality = float(quality_raw)
830
+ except ValueError:
831
+ self.notify("Quality must be a number.", severity="error")
832
+ return
833
+
834
+ chunks_raw = self.query_one("#chunks", Input).value.strip() or "auto"
835
+ if chunks_raw == "auto":
836
+ chunks: Any = "auto"
837
+ else:
838
+ try:
839
+ chunks = tuple(int(part) for part in chunks_raw.split())
840
+ except ValueError:
841
+ self.notify(
842
+ "Chunks must be 'auto' or space-separated integers.", severity="error"
843
+ )
844
+ return
845
+
846
+ try:
847
+ workers = _positive_int(self.query_one("#workers", Input).value, os.cpu_count() or 1)
848
+ except ValueError as exc:
849
+ self.notify(str(exc), severity="error")
850
+ return
851
+
852
+ levels_raw = self.query_one("#pyramid-levels", Input).value.strip() or "auto"
853
+ if levels_raw == "auto":
854
+ pyramid_levels: Any = "auto"
855
+ else:
856
+ try:
857
+ pyramid_levels = _positive_int(levels_raw, 1)
858
+ except ValueError:
859
+ self.notify("Pyramid levels must be 'auto' or a positive integer.", severity="error")
860
+ return
861
+ try:
862
+ pyramid_downscale = _positive_int(self.query_one("#pyramid-downscale", Input).value, 2)
863
+ pyramid_min_size = _positive_int(
864
+ self.query_one("#pyramid-min-size", Input).value, DEFAULT_PYRAMID_MIN_SIZE
865
+ )
866
+ except ValueError as exc:
867
+ self.notify(str(exc), severity="error")
868
+ return
869
+ if pyramid_downscale < 2:
870
+ self.notify("Pyramid downscale must be at least 2.", severity="error")
871
+ return
872
+
873
+ image_key = self.query_one("#image-key", Input).value.strip() or None
874
+ sibling = self.query_one("#sibling", Checkbox).value
875
+ overwrite = self.query_one("#overwrite", Checkbox).value
876
+ pyramid = self.query_one("#pyramid", Checkbox).value
877
+ pyramid_force = self.query_one("#pyramid-force", Checkbox).value
878
+ self.app.context.zarr_path = source
879
+
880
+ def runner() -> dict[str, Any]:
881
+ from ..images import recompress_spatialdata
882
+
883
+ return recompress_spatialdata(
884
+ source,
885
+ dest,
886
+ image_key=image_key,
887
+ codec=codec,
888
+ # An explicit quality overrides the preset, so don't send both.
889
+ preset=None if quality is not None else preset,
890
+ chunks=chunks,
891
+ quality=quality,
892
+ sibling=sibling,
893
+ overwrite=overwrite,
894
+ workers=workers,
895
+ pyramid=pyramid or pyramid_force,
896
+ pyramid_levels=pyramid_levels,
897
+ pyramid_downscale=pyramid_downscale,
898
+ pyramid_min_size=pyramid_min_size,
899
+ pyramid_force=pyramid_force,
900
+ ).manifest
901
+
902
+ dest_exists = Path(dest).exists()
903
+ task = TaskSpec(
904
+ command=CommandId.RECOMPRESS,
905
+ title="Recompress images",
906
+ runner=runner,
907
+ requires_confirm=dest_exists,
908
+ confirm_message=(
909
+ f"Destination already exists and will be replaced:\n{dest}\n\nProceed?"
910
+ ),
911
+ )
912
+ if dest_exists and not overwrite:
913
+ self.notify(
914
+ "Destination exists — tick 'Overwrite destination' to replace it.",
915
+ severity="error",
916
+ )
917
+ return
918
+ self.app.push_screen(ConfirmScreen(task) if task.requires_confirm else RunScreen(task))
919
+
920
+
921
+ class TablesToCscScreen(InputFormScreen):
922
+ """Convert table matrices to CSC — the `tables to-csc` command."""
923
+
924
+ INPUT_ORDER = ("source", "dest", "tables")
925
+
926
+ def compose(self) -> ComposeResult:
927
+ yield SafeHeader()
928
+ yield Static("Convert tables to CSC", classes="screen-title")
929
+ with VerticalScroll():
930
+ yield Static(
931
+ "CSC makes reading one variable (gene) a single contiguous range read "
932
+ "instead of a scan of every row. Values are unchanged.",
933
+ classes="section-label",
934
+ )
935
+ yield Label("Source store")
936
+ yield Input(
937
+ value=self.app.context.zarr_path or "",
938
+ placeholder="/path/to/store.zarr",
939
+ id="source",
940
+ )
941
+ yield Label("Destination store (blank = rewrite source in place)")
942
+ yield Input(placeholder="/path/to/output.zarr", id="dest")
943
+ yield Label("Table keys, comma-separated (blank = all tables)")
944
+ yield Input(placeholder="table", id="tables")
945
+ yield Checkbox("Convert named layers too", value=True, id="layers")
946
+ yield Checkbox("Also convert dense matrices (may increase size)", id="densify")
947
+ yield Checkbox("Overwrite destination if it exists", id="overwrite")
948
+ with Horizontal():
949
+ yield Button("Run", variant="primary", id="run")
950
+ yield Button("Back", id="back")
951
+ yield Footer()
952
+
953
+ def on_button_pressed(self, event: Button.Pressed) -> None:
954
+ if event.button.id == "back":
955
+ self.action_go_back()
956
+ return
957
+ self._submit()
958
+
959
+ def _submit(self) -> None:
960
+ source = self.query_one("#source", Input).value.strip()
961
+ if not source:
962
+ self.notify("Source store is required.", severity="error")
963
+ return
964
+ if not Path(source).is_dir():
965
+ self.notify(f"Not a directory: {source}", severity="error")
966
+ return
967
+
968
+ dest = self.query_one("#dest", Input).value.strip() or None
969
+ tables_raw = self.query_one("#tables", Input).value.strip()
970
+ tables = [key.strip() for key in tables_raw.split(",") if key.strip()] or None
971
+ layers = self.query_one("#layers", Checkbox).value
972
+ densify = self.query_one("#densify", Checkbox).value
973
+ overwrite = self.query_one("#overwrite", Checkbox).value
974
+ self.app.context.zarr_path = source
975
+
976
+ def runner() -> dict[str, Any]:
977
+ return run_tables_to_csc(
978
+ source,
979
+ dest,
980
+ tables=tables,
981
+ layers=layers,
982
+ densify=densify,
983
+ overwrite=overwrite,
984
+ )
985
+
986
+ in_place = dest is None
987
+ dest_exists = dest is not None and Path(dest).exists()
988
+ if dest_exists and not overwrite:
989
+ # The run would fail on this deep inside the conversion; say so while
990
+ # the form is still on screen and editable.
991
+ self.notify(
992
+ f"Destination already exists: {dest}\nTick overwrite to replace it.",
993
+ severity="error",
994
+ )
995
+ return
996
+
997
+ scope = "All tables" if tables is None else "Tables: " + ", ".join(tables)
998
+ if in_place:
999
+ confirm_message = (
1000
+ f"Rewrite table matrices in place:\n{source}\n\n{scope}\n\n"
1001
+ "Values are preserved; only the sparse layout changes. Proceed?"
1002
+ )
1003
+ else:
1004
+ confirm_message = (
1005
+ f"Replace the existing store:\n{dest}\n\n{scope}\n\n"
1006
+ "Its current contents are deleted before the copy. Proceed?"
1007
+ )
1008
+
1009
+ # Anything that destroys existing data is confirmed, whether that is the
1010
+ # source (in place) or the destination (overwrite).
1011
+ needs_confirm = in_place or dest_exists
1012
+ task = TaskSpec(
1013
+ command=CommandId.TABLES_TO_CSC,
1014
+ title="Tables to CSC",
1015
+ runner=runner,
1016
+ requires_confirm=needs_confirm,
1017
+ confirm_message=confirm_message,
1018
+ )
1019
+ self.app.push_screen(ConfirmScreen(task) if needs_confirm else RunScreen(task))
1020
+
1021
+
1022
+ class ConfirmScreen(WriterScreen):
1023
+ BINDINGS = [
1024
+ Binding("enter", "confirm", "Confirm overwrite"),
1025
+ Binding("escape", "cancel", "Cancel"),
1026
+ ]
1027
+
1028
+ def __init__(self, task_spec: TaskSpec) -> None:
1029
+ super().__init__()
1030
+ self.task_spec = task_spec
1031
+ self._handled = False
1032
+
1033
+ def compose(self) -> ComposeResult:
1034
+ yield SafeHeader()
1035
+ yield Static("Confirm in-place write", classes="screen-title")
1036
+ yield Static(self.task_spec.confirm_message, id="confirm-message")
1037
+ with Horizontal():
1038
+ yield Button("Confirm overwrite", variant="error", id="confirm")
1039
+ yield Button("Cancel", id="cancel")
1040
+ yield Footer()
1041
+
1042
+ def on_mount(self) -> None:
1043
+ self.query_one("#confirm", Button).focus()
1044
+
1045
+ def action_confirm(self) -> None:
1046
+ self._confirm()
1047
+
1048
+ def action_cancel(self) -> None:
1049
+ self._cancel()
1050
+
1051
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1052
+ event.stop()
1053
+ if event.button.id == "cancel":
1054
+ self._cancel()
1055
+ return
1056
+ self._confirm()
1057
+
1058
+ def _confirm(self) -> None:
1059
+ if self._handled:
1060
+ return
1061
+ self._handled = True
1062
+ self.app.pop_screen()
1063
+ self.app.push_screen(RunScreen(self.task_spec))
1064
+
1065
+ def _cancel(self) -> None:
1066
+ if self._handled:
1067
+ return
1068
+ self._handled = True
1069
+ self.app.pop_screen()
1070
+
1071
+
1072
+ class RunScreen(WriterScreen):
1073
+ def __init__(self, task_spec: TaskSpec) -> None:
1074
+ super().__init__()
1075
+ self.task_spec = task_spec
1076
+
1077
+ def compose(self) -> ComposeResult:
1078
+ yield SafeHeader()
1079
+ yield Static(self.task_spec.title, classes="screen-title")
1080
+ yield RichLog(id="run-log", highlight=True, markup=False)
1081
+ yield Footer()
1082
+
1083
+ def on_mount(self) -> None:
1084
+ self.run_task()
1085
+
1086
+ @work(thread=True)
1087
+ def run_task(self) -> None:
1088
+ log = self.query_one("#run-log", RichLog)
1089
+ result: dict[str, Any] | None = None
1090
+ error: str | None = None
1091
+ checks: list[VerifyCheck] = []
1092
+ try:
1093
+ self.app.call_from_thread(
1094
+ log.write, f"Running {self.task_spec.command.value}..."
1095
+ )
1096
+ result = self.task_spec.runner()
1097
+ self.app.call_from_thread(
1098
+ log.write, json.dumps(result, indent=2, sort_keys=True)
1099
+ )
1100
+ checks = self._verify()
1101
+ except WriterCommandError as exc:
1102
+ error = str(exc)
1103
+ self.app.call_from_thread(log.write, error)
1104
+ except Exception as exc:
1105
+ error = "".join(traceback.format_exception_only(exc)).strip()
1106
+ self.app.call_from_thread(log.write, error)
1107
+
1108
+ self.app.call_from_thread(
1109
+ self.app.push_screen,
1110
+ VerifyReportScreen(
1111
+ task_spec=self.task_spec,
1112
+ result=result,
1113
+ checks=checks,
1114
+ error=error,
1115
+ ),
1116
+ )
1117
+
1118
+ def _verify(self) -> list[VerifyCheck]:
1119
+ if self.task_spec.verify_kind == "none":
1120
+ return []
1121
+ if self.task_spec.verify_kind == "morton":
1122
+ checks: list[VerifyCheck] = []
1123
+ for path in self.task_spec.verify_paths:
1124
+ checks.extend(verify_morton_parquet(path))
1125
+ return checks
1126
+ if self.task_spec.verify_kind == "multiscale":
1127
+ checks = []
1128
+ for path in self.task_spec.verify_paths:
1129
+ checks.extend(verify_multiscale_parquet(path))
1130
+ return checks
1131
+ if self.task_spec.verify_kind == "manifest":
1132
+ checks = []
1133
+ for path in self.task_spec.verify_paths:
1134
+ checks.extend(verify_index_permutations_manifest(path))
1135
+ return checks
1136
+ return []
1137
+
1138
+
1139
+ class VerifyReportScreen(WriterScreen):
1140
+ BINDINGS = [
1141
+ Binding("enter", "go_home", "Home"),
1142
+ Binding("escape", "go_home", "Home"),
1143
+ ]
1144
+
1145
+ def __init__(
1146
+ self,
1147
+ *,
1148
+ task_spec: TaskSpec,
1149
+ result: dict[str, Any] | None,
1150
+ checks: list[VerifyCheck],
1151
+ error: str | None,
1152
+ ) -> None:
1153
+ super().__init__()
1154
+ self.task_spec = task_spec
1155
+ self.result = result
1156
+ self.checks = checks
1157
+ self.error = error
1158
+ self._handled = False
1159
+
1160
+ def compose(self) -> ComposeResult:
1161
+ yield SafeHeader()
1162
+ yield Static("Run complete", classes="screen-title")
1163
+ with VerticalScroll():
1164
+ if self.error:
1165
+ yield Static(f"Error: {self.error}", id="error-text")
1166
+ elif self.result:
1167
+ yield from self._summary_lines(self.result)
1168
+ if self.checks:
1169
+ passed = all_passed(self.checks)
1170
+ status = "All checks passed" if passed else "Some checks failed"
1171
+ yield Static(f"Verification: {status}", id="verify-summary")
1172
+ table = DataTable(id="verify-table")
1173
+ table.add_columns("Check", "Status", "Detail")
1174
+ for check in self.checks:
1175
+ table.add_row(
1176
+ check.id,
1177
+ "PASS" if check.passed else "FAIL",
1178
+ check.detail,
1179
+ )
1180
+ yield table
1181
+ with Horizontal():
1182
+ yield Button("Home", variant="primary", id="home")
1183
+ yield Button("Quit", id="quit")
1184
+ yield Footer()
1185
+
1186
+ def _summary_lines(self, result: dict[str, Any]) -> ComposeResult:
1187
+ """Render the headline facts per command; the full JSON is on the run log."""
1188
+ command = self.task_spec.command
1189
+ if command == CommandId.LIST_POINTS:
1190
+ keys = result.get("points_keys", [])
1191
+ yield Static(f"Points keys: {', '.join(keys) or '(none)'}")
1192
+ elif command == CommandId.LIST_TABLES:
1193
+ keys = result.get("table_keys", [])
1194
+ yield Static(f"Table keys: {', '.join(keys) or '(none)'}")
1195
+ elif command == CommandId.CODECS_INFO:
1196
+ yield Static(f"Selected backend: {result.get('selected') or '(none available)'}")
1197
+ for name, info in (result.get("backends") or {}).items():
1198
+ available = "available" if info.get("available") else "not available"
1199
+ probe = "probe OK" if info.get("passes_multicomponent_probe") else "probe FAILED"
1200
+ yield Static(f" {name}: {available}, {probe}")
1201
+ elif command == CommandId.RECOMPRESS:
1202
+ images = result.get("images") or []
1203
+ yield Static(f"Output: {result.get('output')}")
1204
+ yield Static(f"Rasters recompressed: {len(images)}")
1205
+ total = sum(int(image.get("encoded_bytes") or 0) for image in images)
1206
+ if total:
1207
+ yield Static(f"Encoded bytes: {total:,}")
1208
+ backend = (result.get("htj2k") or {}).get("selected")
1209
+ if backend:
1210
+ yield Static(f"HTJ2K backend: {backend}")
1211
+ for entry in result.get("pyramids") or []:
1212
+ if entry.get("action") == "rebuilt":
1213
+ built = ", ".join(entry.get("levels") or [])
1214
+ yield Static(f" pyramid {entry['path']}: {built}")
1215
+ else:
1216
+ yield Static(f" pyramid {entry['path']}: skipped ({entry.get('reason')})")
1217
+ elif command == CommandId.TABLES_TO_CSC:
1218
+ yield Static(f"Output: {result.get('output')}")
1219
+ for table in result.get("tables") or []:
1220
+ matrices = ", ".join(
1221
+ f"{key} {value}" for key, value in (table.get("matrices") or {}).items()
1222
+ )
1223
+ yield Static(f" {table.get('path')}: {matrices or '(no matrices)'}")
1224
+ else:
1225
+ rows = result.get("rows")
1226
+ output = result.get("output")
1227
+ if rows is not None:
1228
+ yield Static(f"Rows: {rows}")
1229
+ if output:
1230
+ yield Static(f"Output: {output}")
1231
+
1232
+ def on_mount(self) -> None:
1233
+ self.query_one("#home", Button).focus()
1234
+
1235
+ def action_go_home(self) -> None:
1236
+ self._go_home()
1237
+
1238
+ def on_button_pressed(self, event: Button.Pressed) -> None:
1239
+ event.stop()
1240
+ if event.button.id == "home":
1241
+ self._go_home()
1242
+ return
1243
+ self._exit()
1244
+
1245
+ def _go_home(self) -> None:
1246
+ if self._handled:
1247
+ return
1248
+ self._handled = True
1249
+ self.app.go_home()
1250
+
1251
+ def _exit(self) -> None:
1252
+ if self._handled:
1253
+ return
1254
+ self._handled = True
1255
+ self.app.exit()