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/__init__.py +0 -0
- morph/batch.py +492 -0
- morph/cli.py +703 -0
- morph/converters/__init__.py +19 -0
- morph/converters/archives.py +85 -0
- morph/converters/audio.py +58 -0
- morph/converters/csv_json.py +28 -0
- morph/converters/csv_to_xlsx.py +290 -0
- morph/converters/documents.py +252 -0
- morph/converters/ebooks.py +36 -0
- morph/converters/fonts.py +72 -0
- morph/converters/images.py +119 -0
- morph/converters/json_yaml.py +26 -0
- morph/converters/models_3d.py +149 -0
- morph/converters/ocr.py +49 -0
- morph/converters/pandas_extra.py +127 -0
- morph/converters/svg.py +63 -0
- morph/converters/video.py +153 -0
- morph/converters/vtracer_converter.py +84 -0
- morph/converters/web.py +92 -0
- morph/converters/xlsx_to_csv.py +272 -0
- morph/deps.py +228 -0
- morph/ffmpeg_utils.py +79 -0
- morph/history.py +136 -0
- morph/progress.py +78 -0
- morph/registry.py +236 -0
- morph/tui.py +792 -0
- morphconv-1.0.1.dist-info/METADATA +396 -0
- morphconv-1.0.1.dist-info/RECORD +33 -0
- morphconv-1.0.1.dist-info/WHEEL +5 -0
- morphconv-1.0.1.dist-info/entry_points.txt +2 -0
- morphconv-1.0.1.dist-info/licenses/LICENSE +21 -0
- morphconv-1.0.1.dist-info/top_level.txt +1 -0
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()
|