morphconv 1.0.1__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.
morph/tui.py ADDED
@@ -0,0 +1,792 @@
1
+ """
2
+ tui.py — morph's fully keyboard-driven interactive mode.
3
+
4
+ Navigation:
5
+ [f] focus the file input from anywhere
6
+ [↓] open filesystem autocomplete dropdown from file input
7
+ [↑↓] navigate suggestions or the format list
8
+ [Enter] confirm (file path, suggestion, format)
9
+ [Esc] close the autocomplete dropdown
10
+ [Tab] cycle focus: file → formats → options → convert → file
11
+ [Shift+Tab] reverse cycle
12
+ [c] convert (when not typing in a text field)
13
+ [q] quit
14
+
15
+ The options panel updates live as you move through the format list —
16
+ no need to press Enter just to preview what flags are available.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ from pathlib import Path
23
+ from typing import Optional
24
+
25
+ from textual import on, work
26
+ from textual.app import App, ComposeResult
27
+ from textual.binding import Binding
28
+ from textual.containers import Horizontal, Vertical, VerticalScroll
29
+ from textual.message import Message
30
+ from textual.reactive import reactive
31
+ from textual.screen import ModalScreen
32
+ from textual.widgets import (
33
+ Button, Header, Input, Label, ListItem, ListView, Log,
34
+ ProgressBar, Static, Switch,
35
+ )
36
+
37
+ from . import converters # noqa: F401 — registers all converters at import time
38
+ from . import deps
39
+ from .registry import ConverterSpec, OptionSpec, detect_format, registry
40
+
41
+
42
+ # ─────────────────────────────────────────────────────────────────────────────
43
+ # Autocomplete suggestion list
44
+ # ─────────────────────────────────────────────────────────────────────────────
45
+
46
+ class SuggestionList(ListView):
47
+ """
48
+ ListView that emits custom messages instead of silently eating Esc
49
+ and ↑-at-top, so PathInput can return focus to the Input on both.
50
+ """
51
+
52
+ class Escaped(Message):
53
+ """Esc pressed while dropdown is focused."""
54
+
55
+ class UpAtTop(Message):
56
+ """↑ pressed while already at the first item."""
57
+
58
+ def on_key(self, event) -> None:
59
+ if event.key == "escape":
60
+ self.post_message(self.Escaped())
61
+ event.stop()
62
+ elif event.key == "up" and (self.index is None or self.index == 0):
63
+ self.post_message(self.UpAtTop())
64
+ event.stop()
65
+
66
+
67
+ class SuggestionItem(ListItem):
68
+ def __init__(self, path: Path) -> None:
69
+ is_dir = path.is_dir()
70
+ icon = "📁" if is_dir else "📄"
71
+ name = path.name + ("/" if is_dir else "")
72
+ markup = f"{icon} [bold]{name}[/bold]" if is_dir else f"{icon} {name}"
73
+ super().__init__(Label(markup))
74
+ self.suggestion_path = path
75
+
76
+
77
+ # ─────────────────────────────────────────────────────────────────────────────
78
+ # PathInput — file input with live filesystem autocomplete
79
+ # ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ class PathInput(Vertical):
82
+ """
83
+ Composite widget: a single-line Input + a SuggestionList dropdown.
84
+
85
+ Keyboard contract
86
+ ─────────────────
87
+ ↓ (Input focused) open + focus dropdown
88
+ ↑↓ (dropdown) navigate entries
89
+ ↑ at first entry close dropdown, return to Input
90
+ Enter (dropdown) dirs → navigate into; files → confirm and close
91
+ Esc (dropdown) close dropdown, return to Input
92
+ Enter (Input) confirm whatever is typed (no autocomplete needed)
93
+ """
94
+
95
+ class FileConfirmed(Message):
96
+ """Emitted when the user has committed a concrete file path."""
97
+ def __init__(self, path: Path) -> None:
98
+ super().__init__()
99
+ self.path = path
100
+
101
+ def compose(self) -> ComposeResult:
102
+ yield Input(
103
+ placeholder="path/to/file (↓ for autocomplete)",
104
+ id="path_field",
105
+ )
106
+ yield SuggestionList(id="suggestions", classes="dropdown")
107
+
108
+ def on_mount(self) -> None:
109
+ self._dropdown_open = False
110
+ self.query_one("#suggestions", SuggestionList).display = False
111
+
112
+ # ── suggestion population ─────────────────────────────────────────────────
113
+
114
+ @on(Input.Changed, "#path_field")
115
+ def _on_text_changed(self, event: Input.Changed) -> None:
116
+ self._populate(event.value)
117
+
118
+ def _populate(self, text: str) -> None:
119
+ lv = self.query_one("#suggestions", SuggestionList)
120
+ lv.clear()
121
+ if not text.strip():
122
+ self._close()
123
+ return
124
+ try:
125
+ p = Path(text).expanduser()
126
+ if text[-1] in "/\\":
127
+ parent, stem = p, ""
128
+ else:
129
+ parent, stem = p.parent, p.name.lower()
130
+ if not parent.is_dir():
131
+ self._close()
132
+ return
133
+ hits = sorted(
134
+ [c for c in parent.iterdir() if c.name.lower().startswith(stem)],
135
+ key=lambda c: (c.is_file(), c.name.lower()),
136
+ )[:8]
137
+ if not hits:
138
+ self._close()
139
+ return
140
+ for h in hits:
141
+ lv.append(SuggestionItem(h))
142
+ self._open()
143
+ except Exception:
144
+ self._close()
145
+
146
+ def _open(self) -> None:
147
+ self.query_one("#suggestions", SuggestionList).display = True
148
+ self._dropdown_open = True
149
+
150
+ def _close(self) -> None:
151
+ self.query_one("#suggestions", SuggestionList).display = False
152
+ self._dropdown_open = False
153
+
154
+ # ── key handling ──────────────────────────────────────────────────────────
155
+
156
+ def on_key(self, event) -> None:
157
+ inp = self.query_one("#path_field", Input)
158
+ if not inp.has_focus:
159
+ return
160
+ if event.key == "down":
161
+ self._populate(inp.value)
162
+ if self._dropdown_open:
163
+ lv = self.query_one("#suggestions", SuggestionList)
164
+ lv.index = 0
165
+ lv.focus()
166
+ event.stop()
167
+ elif event.key == "enter":
168
+ self._close()
169
+ path = Path(inp.value.strip()).expanduser()
170
+ self.post_message(self.FileConfirmed(path))
171
+ event.stop()
172
+
173
+ # ── dropdown → Input coordination ─────────────────────────────────────────
174
+
175
+ def on_suggestion_list_escaped(self, _: SuggestionList.Escaped) -> None:
176
+ self._close()
177
+ self.query_one("#path_field", Input).focus()
178
+
179
+ def on_suggestion_list_up_at_top(self, _: SuggestionList.UpAtTop) -> None:
180
+ self._close()
181
+ self.query_one("#path_field", Input).focus()
182
+
183
+ @on(ListView.Selected, "#suggestions")
184
+ def _on_selected(self, event: ListView.Selected) -> None:
185
+ item = event.item
186
+ if not isinstance(item, SuggestionItem):
187
+ return
188
+ p = item.suggestion_path
189
+ inp = self.query_one("#path_field", Input)
190
+ if p.is_dir():
191
+ # Navigate into the directory — keep dropdown open with new contents
192
+ inp.value = str(p) + "/"
193
+ inp.cursor_position = len(inp.value)
194
+ self._close()
195
+ inp.focus()
196
+ self._populate(inp.value)
197
+ else:
198
+ inp.value = str(p)
199
+ inp.cursor_position = len(inp.value)
200
+ self._close()
201
+ inp.focus()
202
+ self.post_message(self.FileConfirmed(p))
203
+
204
+ # ── public api ───────────────────────────────────────────────────────────
205
+
206
+ def focus_input(self) -> None:
207
+ self.query_one("#path_field", Input).focus()
208
+
209
+ @property
210
+ def current_value(self) -> str:
211
+ return self.query_one("#path_field", Input).value
212
+
213
+
214
+ # ─────────────────────────────────────────────────────────────────────────────
215
+ # Format list item
216
+ # ─────────────────────────────────────────────────────────────────────────────
217
+
218
+ class FormatItem(ListItem):
219
+ def __init__(self, fmt: str, hops: int, route: str) -> None:
220
+ hop_tag = f" [dim]{hops} hops[/dim]" if hops > 1 else ""
221
+ super().__init__(Label(f"[bold]{fmt:<8}[/bold] [dim]{route}[/dim]{hop_tag}"))
222
+ self.fmt = fmt
223
+
224
+
225
+ # ─────────────────────────────────────────────────────────────────────────────
226
+ # Option row — one OptionSpec rendered as a labeled input or switch
227
+ # ─────────────────────────────────────────────────────────────────────────────
228
+
229
+ class OptionRow(Horizontal):
230
+ def __init__(self, opt: OptionSpec) -> None:
231
+ super().__init__(classes="option-row")
232
+ self.opt = opt
233
+
234
+ def compose(self) -> ComposeResult:
235
+ flag = self.opt.flags[-1]
236
+ yield Label(flag, classes="option-label")
237
+ if self.opt.action in ("store_true", "store_false"):
238
+ default_on = bool(self.opt.default) if self.opt.action == "store_true" else False
239
+ yield Switch(value=default_on, id=f"opt_{self.opt.name}")
240
+ else:
241
+ placeholder = str(self.opt.default) if self.opt.default is not None else ""
242
+ yield Input(
243
+ placeholder=placeholder,
244
+ id=f"opt_{self.opt.name}",
245
+ classes="opt-input",
246
+ )
247
+ yield Label(f"[dim]{self.opt.help}[/dim]", classes="option-help")
248
+
249
+ def current_value(self) -> object:
250
+ if self.opt.action in ("store_true", "store_false"):
251
+ on = self.query_one(Switch).value
252
+ return on if self.opt.action == "store_true" else (
253
+ not on if on else self.opt.default
254
+ )
255
+ raw = self.query_one(Input).value.strip()
256
+ if not raw:
257
+ return self.opt.default
258
+ try:
259
+ if self.opt.type is int:
260
+ return int(raw)
261
+ if self.opt.type is float:
262
+ return float(raw)
263
+ except ValueError:
264
+ return self.opt.default
265
+ return raw
266
+
267
+
268
+ # ─────────────────────────────────────────────────────────────────────────────
269
+ # Install confirm modal — fully keyboard navigable
270
+ # ─────────────────────────────────────────────────────────────────────────────
271
+
272
+ class InstallConfirmModal(ModalScreen[bool]):
273
+ """
274
+ Blocks until the user accepts or rejects a dep install.
275
+ Once accepted, streams the installer output live.
276
+ Keys: [Enter] → install, [Esc] → cancel.
277
+ """
278
+
279
+ DEFAULT_CSS = """
280
+ InstallConfirmModal { align: center middle; }
281
+ #modal_box {
282
+ width: 72; height: auto; max-height: 26;
283
+ border: heavy #d29922;
284
+ padding: 1 2;
285
+ background: #161b22;
286
+ }
287
+ #modal_log {
288
+ height: 8; margin-top: 1;
289
+ border: round #30363d;
290
+ background: #0d1117;
291
+ }
292
+ #modal_btns { height: auto; margin-top: 1; align: right middle; }
293
+ #btn_install { margin-left: 1; }
294
+ """
295
+ BINDINGS = [
296
+ Binding("escape", "do_cancel", "Cancel"),
297
+ Binding("enter", "do_install", "Install"),
298
+ ]
299
+
300
+ def __init__(self, binary: str, status) -> None:
301
+ super().__init__()
302
+ self.binary = binary
303
+ self.status = status
304
+
305
+ def compose(self) -> ComposeResult:
306
+ with Vertical(id="modal_box"):
307
+ yield Static(
308
+ f"[bold yellow]{self.binary}[/bold yellow] is required but not installed.\n\n"
309
+ f"morph will run:\n [cyan]{self.status.install_cmd}[/cyan]\n\n"
310
+ f"[dim] Enter → install Esc → cancel[/dim]"
311
+ )
312
+ yield Log(id="modal_log", classes="hidden")
313
+ with Horizontal(id="modal_btns"):
314
+ yield Button("Cancel (Esc)", id="btn_cancel", variant="default")
315
+ yield Button("Install (Enter)", id="btn_install", variant="warning")
316
+
317
+ def action_do_cancel(self) -> None:
318
+ self.dismiss(False)
319
+
320
+ def action_do_install(self) -> None:
321
+ self._run_install()
322
+
323
+ def on_button_pressed(self, event: Button.Pressed) -> None:
324
+ if event.button.id == "btn_cancel":
325
+ self.dismiss(False)
326
+ elif event.button.id == "btn_install":
327
+ self._run_install()
328
+
329
+ @work(thread=True)
330
+ def _run_install(self) -> None:
331
+ log = self.query_one("#modal_log", Log)
332
+ self.call_from_thread(log.remove_class, "hidden")
333
+ btn = self.query_one("#btn_install", Button)
334
+ self.call_from_thread(btn.__setattr__, "disabled", True)
335
+
336
+ proc = subprocess.Popen(
337
+ self.status.install_cmd, shell=True,
338
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
339
+ text=True, bufsize=1,
340
+ )
341
+ assert proc.stdout is not None
342
+ for line in proc.stdout:
343
+ self.call_from_thread(log.write_line, line.rstrip())
344
+ proc.wait()
345
+
346
+ ok = proc.returncode == 0 and deps.is_installed(self.binary)
347
+ self.call_from_thread(log.write_line, "✓ done" if ok else "✗ install failed")
348
+ self.call_from_thread(self.dismiss, ok)
349
+
350
+
351
+ # ─────────────────────────────────────────────────────────────────────────────
352
+ # Context-sensitive status bar hints
353
+ # ─────────────────────────────────────────────────────────────────────────────
354
+
355
+ _HINTS: dict[str, str] = {
356
+ "path_field": " ↓ autocomplete Enter confirm Tab jump to formats q quit",
357
+ "suggestions": " ↑↓ navigate Enter select Esc cancel",
358
+ "target_list": " ↑↓ navigate (options update live) Enter jump to options c convert q quit",
359
+ "options_scroll": " Tab next field Shift+Tab prev c convert q quit",
360
+ "convert_btn": " Enter / c convert Shift+Tab back to options q quit",
361
+ "_default": " f file input Tab next panel c convert q quit",
362
+ }
363
+
364
+
365
+ # ─────────────────────────────────────────────────────────────────────────────
366
+ # Main application
367
+ # ─────────────────────────────────────────────────────────────────────────────
368
+
369
+ class MorphApp(App):
370
+
371
+ CSS = """
372
+ /* ── base ──────────────────────────────────────────────────────────── */
373
+ Screen {
374
+ background: #0d1117;
375
+ layout: vertical;
376
+ }
377
+ Header {
378
+ background: #161b22;
379
+ color: #58a6ff;
380
+ text-style: bold;
381
+ }
382
+
383
+ /* ── file section ───────────────────────────────────────────────────── */
384
+ #file_section {
385
+ height: auto;
386
+ padding: 1 2 0 2;
387
+ }
388
+ #file_label {
389
+ color: #8b949e;
390
+ padding: 0 1;
391
+ text-style: bold;
392
+ margin-bottom: 0;
393
+ }
394
+ PathInput { height: auto; }
395
+ #path_field {
396
+ background: #161b22;
397
+ border: tall #30363d;
398
+ color: #c9d1d9;
399
+ }
400
+ #path_field:focus { border: tall #58a6ff; }
401
+ .dropdown {
402
+ background: #0d1117;
403
+ border: round #21262d;
404
+ height: auto;
405
+ max-height: 9;
406
+ }
407
+ .dropdown > ListItem { padding: 0 1; color: #c9d1d9; }
408
+ .dropdown > ListItem.--highlight { background: #1f3d6e; color: #79c0ff; }
409
+
410
+ /* ── body ───────────────────────────────────────────────────────────── */
411
+ #body {
412
+ height: 1fr;
413
+ padding: 1 2;
414
+ }
415
+
416
+ /* ── format panel ───────────────────────────────────────────────────── */
417
+ #formats_panel {
418
+ width: 38%;
419
+ border: round #30363d;
420
+ background: #161b22;
421
+ margin-right: 1;
422
+ }
423
+ #formats_panel:focus-within { border: heavy #58a6ff; }
424
+ #formats_label {
425
+ background: #21262d;
426
+ color: #58a6ff;
427
+ text-style: bold;
428
+ padding: 0 1;
429
+ width: 100%;
430
+ }
431
+ #target_list { background: #161b22; }
432
+ #target_list > ListItem { padding: 0 1; color: #c9d1d9; }
433
+ #target_list > ListItem.--highlight { background: #1f3d6e; color: #79c0ff; }
434
+
435
+ /* ── right panel ────────────────────────────────────────────────────── */
436
+ #right_panel { width: 62%; layout: vertical; }
437
+
438
+ /* options block */
439
+ #options_block {
440
+ height: auto;
441
+ border: round #30363d;
442
+ background: #161b22;
443
+ margin-bottom: 1;
444
+ }
445
+ #options_block:focus-within { border: heavy #58a6ff; }
446
+ #options_label {
447
+ background: #21262d;
448
+ color: #58a6ff;
449
+ text-style: bold;
450
+ padding: 0 1;
451
+ width: 100%;
452
+ }
453
+ #options_scroll {
454
+ height: auto;
455
+ max-height: 13;
456
+ padding: 0 1;
457
+ }
458
+ #no_opts_msg { color: #6e7681; padding: 1; }
459
+
460
+ .option-row { height: 3; align: left middle; }
461
+ .option-label { width: 22; color: #8b949e; text-style: bold; }
462
+ .opt-input {
463
+ width: 18;
464
+ background: #0d1117;
465
+ border: tall #30363d;
466
+ color: #c9d1d9;
467
+ }
468
+ .opt-input:focus { border: tall #58a6ff; }
469
+ Switch { margin: 0 1; }
470
+ .option-help { color: #6e7681; margin-left: 1; width: 1fr; }
471
+
472
+ /* convert row */
473
+ #convert_row { height: 3; padding: 0 1; align: right middle; }
474
+ #convert_btn {
475
+ background: #238636;
476
+ color: #ffffff;
477
+ text-style: bold;
478
+ min-width: 20;
479
+ border: tall #2ea043;
480
+ }
481
+ #convert_btn:focus { background: #2ea043; border: tall #3fb950; }
482
+ #convert_btn:disabled { background: #21262d; color: #484f58; border: tall #30363d; }
483
+
484
+ /* ── log panel ──────────────────────────────────────────────────────── */
485
+ #log_panel {
486
+ border: round #30363d;
487
+ background: #161b22;
488
+ height: 1fr;
489
+ }
490
+ #log_label {
491
+ background: #21262d;
492
+ color: #58a6ff;
493
+ text-style: bold;
494
+ padding: 0 1;
495
+ width: 100%;
496
+ }
497
+ #progress_bar { margin: 0 1; }
498
+ #log_view { padding: 0 1; }
499
+
500
+ /* ── status bar ─────────────────────────────────────────────────────── */
501
+ #status_bar {
502
+ height: 1;
503
+ background: #21262d;
504
+ color: #6e7681;
505
+ padding: 0 1;
506
+ dock: bottom;
507
+ }
508
+
509
+ .hidden { display: none; }
510
+ """
511
+
512
+ BINDINGS = [
513
+ Binding("q", "quit", "Quit", show=False),
514
+ Binding("f", "focus_file", "File", show=False),
515
+ Binding("c", "convert", "Convert", show=False),
516
+ ]
517
+
518
+ # ── reactive state ────────────────────────────────────────────────────────
519
+ input_file: reactive[str] = reactive("")
520
+ src_format: reactive[str] = reactive("")
521
+ selected_dst: reactive[str] = reactive("")
522
+ current_path: reactive[list] = reactive(list)
523
+
524
+ # ── layout ────────────────────────────────────────────────────────────────
525
+
526
+ def compose(self) -> ComposeResult:
527
+ yield Header(show_clock=True)
528
+
529
+ # File input + autocomplete
530
+ with Vertical(id="file_section"):
531
+ yield Label("SOURCE FILE", id="file_label")
532
+ yield PathInput(id="path_input_widget")
533
+
534
+ # Main body
535
+ with Horizontal(id="body"):
536
+ # Left — format list
537
+ with Vertical(id="formats_panel"):
538
+ yield Label(" TARGET FORMAT", id="formats_label")
539
+ yield ListView(id="target_list")
540
+
541
+ # Right — options + log
542
+ with Vertical(id="right_panel"):
543
+ # Options block
544
+ with Vertical(id="options_block"):
545
+ yield Label(" OPTIONS", id="options_label")
546
+ with VerticalScroll(id="options_scroll"):
547
+ yield Static(
548
+ "[dim]Select a target format to see its options.[/dim]",
549
+ id="no_opts_msg",
550
+ )
551
+ with Horizontal(id="convert_row"):
552
+ yield Button(
553
+ " Convert (c)",
554
+ id="convert_btn",
555
+ variant="success",
556
+ disabled=True,
557
+ )
558
+ # Log block
559
+ with Vertical(id="log_panel"):
560
+ yield Label(" LOG", id="log_label")
561
+ yield ProgressBar(id="progress_bar", classes="hidden")
562
+ yield Log(id="log_view")
563
+
564
+ yield Static("", id="status_bar")
565
+
566
+ def on_mount(self) -> None:
567
+ log = self.query_one("#log_view", Log)
568
+ log.write_line("morph — keyboard-first file converter")
569
+ log.write_line("type a file path above, then press ↓ for autocomplete or Enter to confirm")
570
+ self.query_one(PathInput).focus_input()
571
+ self._set_hint("path_field")
572
+
573
+ # ── status bar ────────────────────────────────────────────────────────────
574
+
575
+ def _set_hint(self, widget_id: str) -> None:
576
+ self.query_one("#status_bar", Static).update(
577
+ _HINTS.get(widget_id, _HINTS["_default"])
578
+ )
579
+
580
+ def watch_focused(self, focused) -> None:
581
+ """Update the status bar hint whenever focus moves."""
582
+ if focused is not None:
583
+ self._set_hint(focused.id or "_default")
584
+
585
+ # ── file confirmed ────────────────────────────────────────────────────────
586
+
587
+ @on(PathInput.FileConfirmed)
588
+ def _on_file_confirmed(self, event: PathInput.FileConfirmed) -> None:
589
+ path = event.path
590
+ log = self.query_one("#log_view", Log)
591
+ target_list = self.query_one("#target_list", ListView)
592
+
593
+ # Reset state
594
+ target_list.clear()
595
+ self._clear_options()
596
+ self.query_one("#convert_btn", Button).disabled = True
597
+ self.selected_dst = ""
598
+ self.current_path = []
599
+
600
+ if not path.exists():
601
+ log.write_line(f" ✗ Not found: {path}")
602
+ return
603
+ if path.is_dir():
604
+ log.write_line(f" ✗ That's a directory: {path}")
605
+ return
606
+
607
+ src = detect_format(path)
608
+ self.input_file = str(path)
609
+ self.src_format = src
610
+ reachable = registry.reachable_targets(src)
611
+
612
+ if not reachable:
613
+ log.write_line(f" ✗ No known conversions from .{src}")
614
+ return
615
+
616
+ log.write_line(
617
+ f" ✓ {path.name} [{src}] → {len(reachable)} reachable format(s)"
618
+ )
619
+ for dst, hop_path in sorted(reachable.items()):
620
+ route = " → ".join([src] + [s.dst for s in hop_path])
621
+ target_list.append(FormatItem(dst, len(hop_path), route))
622
+
623
+ # Jump focus to the format list
624
+ self.query_one("#target_list", ListView).focus()
625
+
626
+ # ── format list — live options update on highlight ────────────────────────
627
+
628
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
629
+ """Options panel updates live as the user arrows through formats."""
630
+ if event.list_view.id != "target_list":
631
+ return
632
+ item = event.item
633
+ if not isinstance(item, FormatItem) or not self.input_file:
634
+ return
635
+ self._load_format(item.fmt, jump_focus=False)
636
+
637
+ def on_list_view_selected(self, event: ListView.Selected) -> None:
638
+ """Enter on a format → load options AND jump focus to the options panel."""
639
+ if event.list_view.id != "target_list":
640
+ return
641
+ item = event.item
642
+ if not isinstance(item, FormatItem) or not self.input_file:
643
+ return
644
+ self._load_format(item.fmt, jump_focus=True)
645
+
646
+ def _load_format(self, fmt: str, *, jump_focus: bool = False) -> None:
647
+ conv_path = registry.find_path(self.src_format, fmt)
648
+ if conv_path is None:
649
+ return
650
+ self.selected_dst = fmt
651
+ self.current_path = conv_path
652
+
653
+ # Re-render options panel
654
+ self._clear_options()
655
+ scroll = self.query_one("#options_scroll", VerticalScroll)
656
+ no_msg = self.query_one("#no_opts_msg", Static)
657
+ combined = registry.combined_options(conv_path)
658
+
659
+ if combined:
660
+ no_msg.display = False
661
+ for opt in combined:
662
+ scroll.mount(OptionRow(opt))
663
+ else:
664
+ no_msg.display = True
665
+ no_msg.update("[dim]No extra options for this conversion.[/dim]")
666
+
667
+ self.query_one("#convert_btn", Button).disabled = False
668
+
669
+ # Log the route (only on first select to avoid log spam on highlight)
670
+ if jump_focus:
671
+ log = self.query_one("#log_view", Log)
672
+ hop_str = " → ".join([self.src_format] + [s.dst for s in conv_path])
673
+ lossy_tag = " [dim](lossy)[/dim]" if any(s.lossy for s in conv_path) else ""
674
+ log.write_line(f" route: {hop_str}{lossy_tag}")
675
+ self.query_one("#options_scroll", VerticalScroll).focus()
676
+
677
+ def _clear_options(self) -> None:
678
+ scroll = self.query_one("#options_scroll", VerticalScroll)
679
+ for row in list(scroll.query(OptionRow)):
680
+ row.remove()
681
+
682
+ # ── convert ───────────────────────────────────────────────────────────────
683
+
684
+ def action_convert(self) -> None:
685
+ # Don't intercept 'c' typed into a text Input field
686
+ if isinstance(self.focused, Input):
687
+ return
688
+ if not self.current_path or not self.input_file:
689
+ self.query_one("#log_view", Log).write_line(
690
+ " Select a file and a target format first."
691
+ )
692
+ return
693
+ self._convert_worker()
694
+
695
+ def action_focus_file(self) -> None:
696
+ self.query_one(PathInput).focus_input()
697
+
698
+ def on_button_pressed(self, event: Button.Pressed) -> None:
699
+ if event.button.id == "convert_btn":
700
+ # Direct button press — always allowed
701
+ if not self.current_path or not self.input_file:
702
+ return
703
+ self._convert_worker()
704
+
705
+ def _collect_options(self) -> dict:
706
+ return {row.opt.name: row.current_value() for row in self.query(OptionRow)}
707
+
708
+ @work(thread=True)
709
+ def _convert_worker(self) -> None:
710
+ log = self.query_one("#log_view", Log)
711
+ path: list[ConverterSpec] = self.current_path
712
+ options = self._collect_options()
713
+ input_path = Path(self.input_file)
714
+ output_path = input_path.with_suffix(f".{path[-1].dst}")
715
+
716
+ # ── dependency check ─────────────────────────────────────────────────
717
+ needed = registry.required_binaries(path)
718
+ for binary in needed:
719
+ status = deps.check(binary)
720
+ if status.installed:
721
+ continue
722
+ if not status.manager:
723
+ self.call_from_thread(
724
+ log.write_line,
725
+ f" ✗ {binary} not found — install it manually and retry.",
726
+ )
727
+ return
728
+ confirmed = self.call_from_thread(self._ask_install, binary, status)
729
+ if not confirmed:
730
+ self.call_from_thread(log.write_line, f" Skipped — {binary} is required.")
731
+ return
732
+
733
+ # ── conversion ───────────────────────────────────────────────────────
734
+ self.call_from_thread(
735
+ log.write_line,
736
+ f"\n ▶ {input_path.name} → {output_path.name}",
737
+ )
738
+ bar = self.query_one(ProgressBar)
739
+ self.call_from_thread(bar.remove_class, "hidden")
740
+
741
+ current_input = input_path
742
+ try:
743
+ for i, spec in enumerate(path):
744
+ is_last = i == len(path) - 1
745
+ hop_out = (
746
+ output_path if is_last
747
+ else input_path.with_suffix(f".hop{i}.{spec.dst}")
748
+ )
749
+ hop_options = {opt.name: options.get(opt.name) for opt in spec.options}
750
+
751
+ # Progress callback (only wired for specs that support it)
752
+ def _cb(frac: float, _s: str, _bar=bar) -> None:
753
+ self.call_from_thread(_bar.update, progress=frac * 100)
754
+
755
+ if spec.supports_progress:
756
+ self.call_from_thread(bar.update, total=100, progress=0)
757
+ hop_options["_progress"] = _cb
758
+ else:
759
+ self.call_from_thread(bar.update, total=None, progress=0)
760
+
761
+ self.call_from_thread(
762
+ log.write_line,
763
+ f" {spec.src} → {spec.dst} via {spec.backend}",
764
+ )
765
+ result = spec.func(current_input, hop_out, **hop_options)
766
+ current_input = result.output
767
+
768
+ if spec.supports_progress:
769
+ self.call_from_thread(bar.update, total=100, progress=100)
770
+
771
+ except Exception as exc:
772
+ self.call_from_thread(log.write_line, f" ✗ Failed: {exc}")
773
+ self.call_from_thread(bar.add_class, "hidden")
774
+ return
775
+
776
+ self.call_from_thread(log.write_line, f" ✓ Done → {output_path}\n")
777
+ self.call_from_thread(bar.add_class, "hidden")
778
+
779
+ def _ask_install(self, binary: str, status) -> bool:
780
+ # push_screen_wait blocks the worker thread while the modal runs on the
781
+ # UI thread — that's exactly what we want before touching a required binary.
782
+ return self.push_screen_wait(InstallConfirmModal(binary, status))
783
+
784
+
785
+ # ─────────────────────────────────────────────────────────────────────────────
786
+
787
+ def run_tui() -> None:
788
+ MorphApp().run()
789
+
790
+
791
+ if __name__ == "__main__":
792
+ run_tui()