pdfxz 0.2.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.
pdfxz/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """pdfxz - a terminal UI and CLI for compressing PDF files with Ghostscript."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
6
+
7
+ __all__ = ["__version__"]
pdfxz/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
pdfxz/app.py ADDED
@@ -0,0 +1,629 @@
1
+ """The interactive Textual TUI.
2
+
3
+ Three screens, pushed in sequence: `ConfigScreen` (input/output/quality)
4
+ -> `ProcessingScreen` (live progress, cancellable) -> `ReportScreen`
5
+ (summary + per-file table), plus a `DirectoryPicker` modal used by both
6
+ the input and output fields on `ConfigScreen`. The TUI is a thin
7
+ consumer of `BatchRunner` and `PDFCompressor` - all business logic lives
8
+ in `workers.py` and `compressor.py`, so the CLI and the TUI never
9
+ duplicate it.
10
+
11
+ The TUI intentionally exposes fewer knobs than the CLI (no
12
+ recursive/overwrite/create-directory toggles): directories are always
13
+ scanned recursively, missing output directories are always created, and
14
+ an existing output is never overwritten - a fresh, uniquely-named file
15
+ is written alongside it instead (see `utils.unique_output_path`). The
16
+ CLI is unaffected and keeps its explicit flags for scripting.
17
+
18
+ Ghostscript runs inside a Textual thread worker (`@work(thread=True)`),
19
+ never on the UI event loop, so the interface stays responsive and a
20
+ determinate progress bar can be driven by real per-file completion
21
+ events rather than fabricated intra-file percentages.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import threading
28
+ import time
29
+ from collections.abc import Iterable
30
+ from pathlib import Path
31
+ from typing import ClassVar
32
+
33
+ from textual import work
34
+ from textual.app import App, ComposeResult, SystemCommand
35
+ from textual.containers import Horizontal, Vertical, VerticalScroll
36
+ from textual.message import Message
37
+ from textual.reactive import reactive
38
+ from textual.screen import ModalScreen, Screen
39
+ from textual.widgets import (
40
+ Button,
41
+ DataTable,
42
+ DirectoryTree,
43
+ Footer,
44
+ Header,
45
+ Input,
46
+ Static,
47
+ )
48
+ from textual.widgets.data_table import RowKey
49
+
50
+ from .compressor import PDFCompressor
51
+ from .formatting import (
52
+ format_duration,
53
+ format_mb,
54
+ format_size_change,
55
+ truncate_filename,
56
+ )
57
+ from .models import BatchResult, CompressionResult, CompressionStatus
58
+ from .profiles import DEFAULT_QUALITY, QUALITY_PROFILES, get_profile
59
+ from .scanner import discover_pdfs
60
+ from .utils import plan_batch_outputs, plan_single_output, unique_output_path
61
+ from .workers import BatchJob, BatchRunner
62
+
63
+ logger = logging.getLogger("pdfxz.app")
64
+
65
+ # NOTE ON LOGGING SCOPE: per-file and per-batch compression events are
66
+ # already logged by `compressor.py`/`workers.py`, which the TUI calls into
67
+ # via the same `BatchRunner`/`PDFCompressor` used by the CLI - duplicating
68
+ # those log lines here would just be noise. The handful of `logger` calls
69
+ # in this module cover events the TUI alone knows about (e.g. the user
70
+ # pressing Cancel, or Ghostscript being unavailable before a batch even
71
+ # starts), not compression outcomes themselves.
72
+
73
+ _STATUS_LABELS = {
74
+ CompressionStatus.SUCCESS: "OK",
75
+ CompressionStatus.UNCHANGED: "UNCHANGED",
76
+ CompressionStatus.LARGER: "LARGER",
77
+ CompressionStatus.FAILED: "FAILED",
78
+ CompressionStatus.SKIPPED: "SKIPPED",
79
+ CompressionStatus.CANCELLED: "CANCELLED",
80
+ }
81
+
82
+ _FILENAME_COLUMN_WIDTH = 30
83
+
84
+ # Letter-spaced wordmark. Terminal cells can't be individually scaled up
85
+ # the way a GUI font can, so "noticeably larger" is achieved through
86
+ # spacing + weight + a background band (see app.tcss) rather than
87
+ # unusual Unicode glyph forms, which render inconsistently (or as tofu
88
+ # boxes) across terminal fonts.
89
+ _TITLE_TEXT = "P D F X Z"
90
+
91
+
92
+ class QualitySlider(Static, can_focus=True):
93
+ """A compact, volume-control-style widget for picking a compression profile.
94
+
95
+ Backed by the same five `QUALITY_PROFILES` keys used by the CLI's
96
+ `-q/--quality` flag - only the presentation differs.
97
+ """
98
+
99
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
100
+ ("left", "decrease", "Less compression"),
101
+ ("right", "increase", "More compression"),
102
+ ]
103
+
104
+ KEYS: ClassVar[list[str]] = list(QUALITY_PROFILES.keys())
105
+
106
+ index: reactive[int] = reactive(0)
107
+
108
+ class Changed(Message):
109
+ """Posted whenever the selected profile changes."""
110
+
111
+ def __init__(self, key: str) -> None:
112
+ self.key = key
113
+ super().__init__()
114
+
115
+ def __init__(self, *, id: str | None = None) -> None:
116
+ super().__init__(id=id)
117
+ self.index = self.KEYS.index(DEFAULT_QUALITY)
118
+
119
+ @property
120
+ def value(self) -> str:
121
+ return self.KEYS[self.index]
122
+
123
+ def on_mount(self) -> None:
124
+ self._render_text()
125
+
126
+ def watch_index(self) -> None:
127
+ self._render_text()
128
+
129
+ def _render_text(self) -> None:
130
+ profile = QUALITY_PROFILES[self.value]
131
+ blocks = "".join(
132
+ "\u25ae" if i <= self.index else "\u25af" for i in range(len(self.KEYS))
133
+ )
134
+ self.update(f"{blocks} {profile.label}")
135
+
136
+ def action_decrease(self) -> None:
137
+ self._set_index(self.index - 1)
138
+
139
+ def action_increase(self) -> None:
140
+ self._set_index(self.index + 1)
141
+
142
+ def _set_index(self, new_index: int) -> None:
143
+ new_index = max(0, min(len(self.KEYS) - 1, new_index))
144
+ if new_index != self.index:
145
+ self.index = new_index
146
+ self.post_message(self.Changed(self.value))
147
+
148
+ def on_click(self, event) -> None:
149
+ self.focus()
150
+ midpoint = self.size.width / 2
151
+ if event.x < midpoint:
152
+ self.action_decrease()
153
+ else:
154
+ self.action_increase()
155
+
156
+
157
+ class DirectoryPicker(ModalScreen[Path | None]):
158
+ """A file/folder browser built on Textual's built-in `DirectoryTree`.
159
+
160
+ `select_files=True` lets the user pick a `.pdf` file or a folder
161
+ (used for INPUT). `select_files=False` restricts picking to folders
162
+ only (used for OUTPUT, which is always a destination directory - see
163
+ the module docstring for why filenames are handled automatically).
164
+ """
165
+
166
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
167
+ ("escape", "cancel", "Cancel"),
168
+ ("u", "use_folder", "Use this folder"),
169
+ ]
170
+
171
+ def __init__(self, start_path: Path, select_files: bool = True) -> None:
172
+ super().__init__()
173
+ self.start_path = start_path if start_path.is_dir() else start_path.parent
174
+ self.select_files = select_files
175
+
176
+ def compose(self) -> ComposeResult:
177
+ with Vertical(id="picker-body"):
178
+ title = (
179
+ "Select a PDF file or folder"
180
+ if self.select_files
181
+ else "Select a destination folder"
182
+ )
183
+ yield Static(title, id="picker-title")
184
+ yield DirectoryTree(str(self.start_path), id="picker-tree")
185
+ hint = (
186
+ "enter: choose u: use folder esc: cancel"
187
+ if self.select_files
188
+ else "enter: open folder u: use folder esc: cancel"
189
+ )
190
+ yield Static(hint, id="picker-hint")
191
+ with Horizontal(id="picker-actions"):
192
+ yield Button("Use this folder", id="use-folder-button")
193
+ yield Button("Cancel", id="picker-cancel-button")
194
+
195
+ def on_mount(self) -> None:
196
+ self.query_one(DirectoryTree).focus()
197
+
198
+ def on_directory_tree_file_selected(
199
+ self, event: DirectoryTree.FileSelected
200
+ ) -> None:
201
+ path = Path(event.path)
202
+ if self.select_files and path.suffix.lower() == ".pdf":
203
+ self.dismiss(path)
204
+ # Non-PDF files are simply not selectable inputs; ignore the event
205
+ # rather than surfacing an error inside a modal.
206
+
207
+ def on_button_pressed(self, event: Button.Pressed) -> None:
208
+ if event.button.id == "use-folder-button":
209
+ self.action_use_folder()
210
+ elif event.button.id == "picker-cancel-button":
211
+ self.dismiss(None)
212
+
213
+ def action_cancel(self) -> None:
214
+ self.dismiss(None)
215
+
216
+ def action_use_folder(self) -> None:
217
+ tree = self.query_one(DirectoryTree)
218
+ node = tree.cursor_node
219
+ if node is None or node.data is None:
220
+ self.dismiss(self.start_path)
221
+ return
222
+ path = Path(node.data.path)
223
+ if path.is_file():
224
+ path = path.parent
225
+ self.dismiss(path)
226
+
227
+
228
+ class ConfigScreen(Screen):
229
+ """Screen 1: input, output, and compression settings."""
230
+
231
+ # Textual's App already binds ctrl+q (to its own quit action, hidden
232
+ # from the footer) and ctrl+c (to help_quit) with priority=True, so
233
+ # neither key ever reaches a Screen-level binding of the same name -
234
+ # "q" is used here instead so the shortcut is both functional and
235
+ # visible in the footer.
236
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("q", "quit_app", "Quit")]
237
+
238
+ def compose(self) -> ComposeResult:
239
+ yield Header(show_clock=False)
240
+ with VerticalScroll(id="config-body"):
241
+ yield Static(_TITLE_TEXT, id="title")
242
+ yield Static("PDF Compression Utility", id="subtitle")
243
+
244
+ yield Static("INPUT", classes="field-label")
245
+ with Horizontal(classes="field-row"):
246
+ yield Input(placeholder="PDF file or folder…", id="input-field")
247
+ yield Button(
248
+ "Browse…", id="browse-input-button", classes="browse-button"
249
+ )
250
+
251
+ yield Static("OUTPUT", classes="field-label")
252
+ with Horizontal(classes="field-row"):
253
+ yield Input(
254
+ placeholder="Destination folder (optional)…", id="output-field"
255
+ )
256
+ yield Button(
257
+ "Browse…", id="browse-output-button", classes="browse-button"
258
+ )
259
+
260
+ yield Static("QUALITY", classes="field-label")
261
+ yield QualitySlider(id="quality-slider")
262
+
263
+ yield Static("", id="validation-message")
264
+ yield Button(
265
+ "Compress PDFs", id="compress-button", variant="primary", disabled=True
266
+ )
267
+ yield Footer()
268
+
269
+ def on_mount(self) -> None:
270
+ self.query_one("#input-field", Input).focus()
271
+ if not PDFCompressor().is_available():
272
+ logger.warning(
273
+ "Ghostscript not found on PATH; compression is disabled until it's installed."
274
+ )
275
+ self.query_one("#validation-message", Static).update(
276
+ "[red]Ghostscript was not found on PATH. Install it before compressing.[/red]"
277
+ )
278
+
279
+ def on_input_changed(self, event: Input.Changed) -> None:
280
+ self._validate()
281
+
282
+ def _validate(self) -> bool:
283
+ input_text = self.query_one("#input-field", Input).value.strip()
284
+ message = self.query_one("#validation-message", Static)
285
+ button = self.query_one("#compress-button", Button)
286
+
287
+ if not PDFCompressor().is_available():
288
+ button.disabled = True
289
+ return False
290
+
291
+ if not input_text:
292
+ message.update("")
293
+ button.disabled = True
294
+ return False
295
+
296
+ path = Path(input_text).expanduser()
297
+ if not path.exists():
298
+ message.update(f"[red]Input does not exist: {path}[/red]")
299
+ button.disabled = True
300
+ return False
301
+ if path.is_file() and path.suffix.lower() != ".pdf":
302
+ message.update("[red]Input file must be a .pdf[/red]")
303
+ button.disabled = True
304
+ return False
305
+
306
+ message.update("")
307
+ button.disabled = False
308
+ return True
309
+
310
+ def on_button_pressed(self, event: Button.Pressed) -> None:
311
+ if event.button.id == "compress-button":
312
+ self._start_compression()
313
+ elif event.button.id == "browse-input-button":
314
+ self._browse_input()
315
+ elif event.button.id == "browse-output-button":
316
+ self._browse_output()
317
+
318
+ def _browse_input(self) -> None:
319
+ current = self.query_one("#input-field", Input).value.strip()
320
+ start = Path(current).expanduser() if current else Path.home()
321
+ if not start.exists():
322
+ start = Path.home()
323
+ self.app.push_screen(
324
+ DirectoryPicker(start, select_files=True), self._on_input_picked
325
+ )
326
+
327
+ def _on_input_picked(self, path: Path | None) -> None:
328
+ if path is None:
329
+ return
330
+ self.query_one("#input-field", Input).value = str(path)
331
+ self._validate()
332
+
333
+ def _browse_output(self) -> None:
334
+ current = self.query_one("#output-field", Input).value.strip()
335
+ start = Path(current).expanduser() if current else Path.home()
336
+ if not start.exists():
337
+ start = Path.home()
338
+ self.app.push_screen(
339
+ DirectoryPicker(start, select_files=False), self._on_output_picked
340
+ )
341
+
342
+ def _on_output_picked(self, path: Path | None) -> None:
343
+ if path is None:
344
+ return
345
+ self.query_one("#output-field", Input).value = str(path)
346
+
347
+ def _start_compression(self) -> None:
348
+ if not self._validate():
349
+ return
350
+
351
+ input_text = self.query_one("#input-field", Input).value.strip()
352
+ output_text = self.query_one("#output-field", Input).value.strip()
353
+ quality = self.query_one("#quality-slider", QualitySlider).value
354
+
355
+ input_path = Path(input_text).expanduser()
356
+ output_path = Path(output_text).expanduser() if output_text else None
357
+ message = self.query_one("#validation-message", Static)
358
+
359
+ # Directories are always scanned recursively and missing output
360
+ # directories are always created - see the module docstring for
361
+ # why the TUI drops those toggles in favour of these defaults.
362
+ if input_path.is_file():
363
+ target = plan_single_output(input_path, output_path)
364
+ jobs = [
365
+ BatchJob(input_path=input_path, output_path=unique_output_path(target))
366
+ ]
367
+ else:
368
+ discovered = discover_pdfs(input_path, recursive=True)
369
+ if not discovered:
370
+ message.update("[red]No PDF files found in that folder.[/red]")
371
+ return
372
+ output_root = output_path or (
373
+ input_path.parent / f"{input_path.name}_compressed"
374
+ )
375
+ mapping = plan_batch_outputs(discovered, input_path, output_root)
376
+ jobs = [
377
+ BatchJob(input_path=src_path, output_path=unique_output_path(dst_path))
378
+ for src_path, dst_path in mapping.items()
379
+ ]
380
+
381
+ self.app.push_screen(ProcessingScreen(jobs=jobs, quality=quality))
382
+
383
+ def action_quit_app(self) -> None:
384
+ self.app.exit()
385
+
386
+
387
+ class ProcessingScreen(Screen):
388
+ """Screen 2: live progress for a running batch. Cancellable."""
389
+
390
+ # Note: ctrl+c is NOT bound here - Textual's App intercepts it first
391
+ # (priority binding to help_quit), so a Screen-level ctrl+c binding
392
+ # would never fire. Escape is the one real, working shortcut, which
393
+ # is why it's the only one advertised (button label, footer, docs).
394
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [("escape", "cancel", "Cancel")]
395
+
396
+ def __init__(self, jobs: list[BatchJob], quality: str) -> None:
397
+ super().__init__()
398
+ self.jobs = jobs
399
+ self.quality = quality
400
+ self.cancel_event = threading.Event()
401
+ self._successful = 0
402
+ self._failed = 0
403
+ self._start_time = 0.0
404
+
405
+ def compose(self) -> ComposeResult:
406
+ yield Header(show_clock=False)
407
+ with Vertical(id="processing-body"):
408
+ yield Static("Compressing PDFs", id="processing-title")
409
+ yield Static("-", id="current-file")
410
+ yield Static(f"0 / {len(self.jobs)} files", id="files-counter")
411
+ yield Static("0 succeeded · 0 failed", id="result-counters")
412
+ yield Static("", id="current-result")
413
+ yield Static("Elapsed 00:00", id="elapsed-counter")
414
+ yield Button("Cancel (Esc)", id="cancel-button", variant="error")
415
+ yield Footer()
416
+
417
+ def on_mount(self) -> None:
418
+ self._start_time = time.perf_counter()
419
+ self.set_interval(1.0, self._tick_elapsed)
420
+ self.run_compression()
421
+
422
+ def _tick_elapsed(self) -> None:
423
+ elapsed = time.perf_counter() - self._start_time
424
+ self.query_one("#elapsed-counter", Static).update(
425
+ f"Elapsed {format_duration(elapsed)}"
426
+ )
427
+
428
+ @work(thread=True, exclusive=True)
429
+ def run_compression(self) -> None:
430
+ compressor = PDFCompressor()
431
+ runner = BatchRunner(compressor)
432
+ profile = get_profile(self.quality)
433
+
434
+ def on_progress(result: CompressionResult, index: int, total: int) -> None:
435
+ self.app.call_from_thread(self._update_progress, result, index, total)
436
+
437
+ batch = runner.run(
438
+ self.jobs,
439
+ profile,
440
+ # Output paths are pre-uniquified by ConfigScreen, and missing
441
+ # parent directories are always created - see module docstring.
442
+ overwrite=False,
443
+ create_parents=True,
444
+ on_progress=on_progress,
445
+ cancel_event=self.cancel_event,
446
+ )
447
+ self.app.call_from_thread(self._finish, batch)
448
+
449
+ def _update_progress(
450
+ self, result: CompressionResult, index: int, total: int
451
+ ) -> None:
452
+ if result.status == CompressionStatus.CANCELLED:
453
+ pass
454
+ elif result.status.is_completed:
455
+ self._successful += 1
456
+ else:
457
+ self._failed += 1
458
+
459
+ file_widget = self.query_one("#current-file", Static)
460
+ display_name = truncate_filename(result.input_path.name, _FILENAME_COLUMN_WIDTH)
461
+ file_widget.update(display_name)
462
+ file_widget.tooltip = str(result.input_path)
463
+
464
+ self.query_one("#files-counter", Static).update(f"{index} / {total} files")
465
+ self.query_one("#result-counters", Static).update(
466
+ f"{self._successful} succeeded · {self._failed} failed"
467
+ )
468
+
469
+ result_widget = self.query_one("#current-result", Static)
470
+ if result.status.is_completed:
471
+ change = format_size_change(
472
+ result.original_size, result.compressed_size or 0
473
+ )
474
+ result_widget.update(
475
+ f"{format_mb(result.original_size)} \u2192 {format_mb(result.compressed_size or 0)} ({change})"
476
+ )
477
+ elif result.error:
478
+ result_widget.update(f"[red]{result.error}[/red]")
479
+
480
+ def _finish(self, batch: BatchResult) -> None:
481
+ self.app.pop_screen()
482
+ self.app.push_screen(ReportScreen(batch))
483
+
484
+ def action_cancel(self) -> None:
485
+ if not self.cancel_event.is_set():
486
+ logger.info(
487
+ "User requested batch cancellation (%d job(s) queued).", len(self.jobs)
488
+ )
489
+ self.cancel_event.set()
490
+ self.query_one("#current-result", Static).update(
491
+ "[yellow]Cancelling after the current file…[/yellow]"
492
+ )
493
+
494
+ def on_button_pressed(self, event: Button.Pressed) -> None:
495
+ if event.button.id == "cancel-button":
496
+ self.action_cancel()
497
+
498
+
499
+ class ReportScreen(Screen):
500
+ """Screen 3: final summary and a per-file results table."""
501
+
502
+ BINDINGS: ClassVar[list[tuple[str, str, str]]] = [
503
+ ("q", "quit_app", "Quit"),
504
+ ("n", "new_batch", "New batch"),
505
+ ]
506
+
507
+ def __init__(self, batch: BatchResult) -> None:
508
+ super().__init__()
509
+ self.batch = batch
510
+ self._results_by_row: dict[RowKey, CompressionResult] = {}
511
+
512
+ def compose(self) -> ComposeResult:
513
+ yield Header(show_clock=False)
514
+ with VerticalScroll(id="report-body"):
515
+ yield Static("PDFXZ REPORT", id="report-title")
516
+ yield Static(self._summary_text(), id="report-summary")
517
+ yield DataTable(id="report-table")
518
+ yield Static("", id="selected-file-path")
519
+ with Horizontal(id="report-actions"):
520
+ yield Button("New batch", id="new-batch-button", variant="primary")
521
+ yield Button("Quit", id="quit-button", variant="error")
522
+ yield Footer()
523
+
524
+ def _summary_text(self) -> str:
525
+ batch = self.batch
526
+ lines = [
527
+ f"Files discovered {batch.total}",
528
+ f"Successful {batch.successful}",
529
+ f"Failed {batch.failed}",
530
+ ]
531
+ if batch.skipped:
532
+ lines.append(f"Skipped (invalid) {batch.skipped}")
533
+ if batch.cancelled_count:
534
+ lines.append(f"Cancelled {batch.cancelled_count}")
535
+ lines += [
536
+ "",
537
+ f"Original size {format_mb(batch.original_size)}",
538
+ f"Compressed size {format_mb(batch.compressed_size)}",
539
+ f"Total saved {format_mb(batch.saved_bytes)}",
540
+ f"Reduction {batch.reduction_percent:.2f}%",
541
+ f"Elapsed time {format_duration(batch.elapsed_seconds)}",
542
+ ]
543
+ largest = batch.largest_saving
544
+ if largest and largest.saved_bytes > 0:
545
+ lines += [
546
+ "",
547
+ f"Largest saving: {truncate_filename(largest.input_path.name)} -{format_mb(largest.saved_bytes)}",
548
+ ]
549
+ return "\n".join(lines)
550
+
551
+ def on_mount(self) -> None:
552
+ table = self.query_one(DataTable)
553
+ table.cursor_type = "row"
554
+ table.add_column("File", width=_FILENAME_COLUMN_WIDTH)
555
+ table.add_column("Status", width=10)
556
+ table.add_column("Before", width=10)
557
+ table.add_column("After", width=10)
558
+ table.add_column("Saved", width=12)
559
+ for result in self.batch.results:
560
+ if result.status.is_completed:
561
+ saved = f"{result.reduction_percent:.0f}%"
562
+ elif result.status == CompressionStatus.SKIPPED:
563
+ saved = "invalid"
564
+ elif result.status == CompressionStatus.CANCELLED:
565
+ saved = "--"
566
+ else:
567
+ saved = "error"
568
+ row_key = table.add_row(
569
+ truncate_filename(result.input_path.name, _FILENAME_COLUMN_WIDTH),
570
+ _STATUS_LABELS[result.status],
571
+ format_mb(result.original_size) if result.original_size else "--",
572
+ format_mb(result.compressed_size)
573
+ if result.compressed_size is not None
574
+ else "--",
575
+ saved,
576
+ )
577
+ self._results_by_row[row_key] = result
578
+ if self.batch.results:
579
+ self._show_full_path(self.batch.results[0])
580
+
581
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
582
+ result = self._results_by_row.get(event.row_key)
583
+ if result is not None:
584
+ self._show_full_path(result)
585
+
586
+ def _show_full_path(self, result: CompressionResult) -> None:
587
+ # DataTable has no per-cell tooltip support, so the full path (and,
588
+ # for a failed/skipped file, the reason) is surfaced here instead -
589
+ # the nearest equivalent this framework offers to a hover tooltip.
590
+ text = str(result.input_path)
591
+ if not result.status.is_completed and result.error:
592
+ text = f"{text} \u2014 {result.error}"
593
+ self.query_one("#selected-file-path", Static).update(text)
594
+
595
+ def on_button_pressed(self, event: Button.Pressed) -> None:
596
+ if event.button.id == "quit-button":
597
+ self.app.exit()
598
+ elif event.button.id == "new-batch-button":
599
+ self.app.pop_screen()
600
+
601
+ def action_quit_app(self) -> None:
602
+ self.app.exit()
603
+
604
+ def action_new_batch(self) -> None:
605
+ self.app.pop_screen()
606
+
607
+
608
+ class PDFXZApp(App):
609
+ """The pdfxz Textual application."""
610
+
611
+ CSS_PATH = "app.tcss"
612
+ TITLE = "pdfxz"
613
+
614
+ def on_mount(self) -> None:
615
+ self.push_screen(ConfigScreen())
616
+
617
+ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
618
+ # Drop the built-in "Screenshot" command - it has no relevance to
619
+ # pdfxz and isn't something we want to support/maintain. Every
620
+ # other default system command (Theme, Quit, Keys, Maximize/...)
621
+ # is left intact.
622
+ for command in super().get_system_commands(screen):
623
+ if command.title == "Screenshot":
624
+ continue
625
+ yield command
626
+
627
+
628
+ def run() -> None: # pragma: no cover - thin convenience wrapper
629
+ PDFXZApp().run()