pdf-tool-cli 1.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.
- pdf_tool_cli-1.1.0.dist-info/METADATA +179 -0
- pdf_tool_cli-1.1.0.dist-info/RECORD +66 -0
- pdf_tool_cli-1.1.0.dist-info/WHEEL +4 -0
- pdf_tool_cli-1.1.0.dist-info/entry_points.txt +4 -0
- pdf_tool_cli-1.1.0.dist-info/licenses/LICENSE +21 -0
- pdftool/__init__.py +3 -0
- pdftool/cli/__init__.py +179 -0
- pdftool/cli/__main__.py +5 -0
- pdftool/cli/args.py +138 -0
- pdftool/cli/report.py +71 -0
- pdftool/core/__init__.py +0 -0
- pdftool/core/atomic.py +43 -0
- pdftool/core/batch.py +111 -0
- pdftool/core/config.py +89 -0
- pdftool/core/errors.py +36 -0
- pdftool/core/jobs.py +188 -0
- pdftool/core/logger.py +84 -0
- pdftool/core/naming.py +91 -0
- pdftool/core/plugin.py +96 -0
- pdftool/core/registry.py +59 -0
- pdftool/core/thumbnails.py +37 -0
- pdftool/core/updater.py +67 -0
- pdftool/main.py +30 -0
- pdftool/tools/__init__.py +0 -0
- pdftool/tools/compress/__init__.py +0 -0
- pdftool/tools/compress/logic.py +387 -0
- pdftool/tools/compress/panel.py +72 -0
- pdftool/tools/compress/params.py +18 -0
- pdftool/tools/compress/spec.py +18 -0
- pdftool/tools/images2pdf/__init__.py +0 -0
- pdftool/tools/images2pdf/logic.py +127 -0
- pdftool/tools/images2pdf/panel.py +22 -0
- pdftool/tools/images2pdf/params.py +6 -0
- pdftool/tools/images2pdf/spec.py +19 -0
- pdftool/tools/merge/__init__.py +0 -0
- pdftool/tools/merge/logic.py +54 -0
- pdftool/tools/merge/panel.py +44 -0
- pdftool/tools/merge/params.py +20 -0
- pdftool/tools/merge/spec.py +19 -0
- pdftool/tools/protect/__init__.py +0 -0
- pdftool/tools/protect/logic.py +85 -0
- pdftool/tools/protect/panel.py +72 -0
- pdftool/tools/protect/params.py +18 -0
- pdftool/tools/protect/spec.py +18 -0
- pdftool/tools/split/__init__.py +0 -0
- pdftool/tools/split/logic.py +139 -0
- pdftool/tools/split/panel.py +129 -0
- pdftool/tools/split/params.py +28 -0
- pdftool/tools/split/spec.py +19 -0
- pdftool/tools/watermark/__init__.py +0 -0
- pdftool/tools/watermark/logic.py +110 -0
- pdftool/tools/watermark/panel.py +67 -0
- pdftool/tools/watermark/params.py +19 -0
- pdftool/tools/watermark/spec.py +19 -0
- pdftool/ui/__init__.py +0 -0
- pdftool/ui/accessibility.py +44 -0
- pdftool/ui/app.py +425 -0
- pdftool/ui/errors.py +12 -0
- pdftool/ui/file_list.py +275 -0
- pdftool/ui/logs.py +58 -0
- pdftool/ui/output_dir.py +127 -0
- pdftool/ui/panel_base.py +631 -0
- pdftool/ui/platform.py +30 -0
- pdftool/ui/registry.py +61 -0
- pdftool/ui/theme.py +30 -0
- pdftool/ui/thumbnails.py +148 -0
pdftool/ui/file_list.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import flet as ft
|
|
7
|
+
|
|
8
|
+
from pdftool.core.plugin import ToolResult
|
|
9
|
+
from pdftool.core.thumbnails import THUMBNAIL_HEIGHT_PX
|
|
10
|
+
from pdftool.ui.accessibility import NamedIconButton
|
|
11
|
+
from pdftool.ui.panel_base import WEB_MODE_MSG, BaseToolPanel
|
|
12
|
+
from pdftool.ui.platform import open_file, shortcut_label
|
|
13
|
+
from pdftool.ui.thumbnails import MISSING, ThumbnailTask, get_cached, load_async
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MultiFileToolPanel(BaseToolPanel):
|
|
17
|
+
"""Panel de lotes con lista, orden, resultados y miniaturas opcionales."""
|
|
18
|
+
|
|
19
|
+
show_thumbnails: bool = False # solo tools donde el orden/identidad importa
|
|
20
|
+
# Flechas de orden solo donde el orden cambia el resultado (Unir): en las
|
|
21
|
+
# herramientas 1→1 sugieren algo que no ocurre.
|
|
22
|
+
reorderable: bool = False
|
|
23
|
+
|
|
24
|
+
def build_input(self, page) -> ft.Control:
|
|
25
|
+
previous_task = getattr(self, "_thumb_task", None)
|
|
26
|
+
if previous_task is not None:
|
|
27
|
+
previous_task.cancel()
|
|
28
|
+
self._files: list[Path] = []
|
|
29
|
+
self._results: list[str] = [] # etiqueta por archivo tras un run
|
|
30
|
+
self._failed: list[bool] = [] # fila cuyo archivo falló en el run
|
|
31
|
+
self._row_warnings: list[tuple[str, ...]] = [] # avisos por fila
|
|
32
|
+
self._row_paths: list[Path | None] = [] # ruta por fila exitosa tras un run
|
|
33
|
+
self._thumb_boxes: dict[str, ft.Container] = {}
|
|
34
|
+
self._thumb_task: ThumbnailTask | None = None
|
|
35
|
+
self._thumb_generation = getattr(self, "_thumb_generation", -1) + 1
|
|
36
|
+
self._picker.on_result = self._on_pick
|
|
37
|
+
self._clear_btn = ft.OutlinedButton(
|
|
38
|
+
"Limpiar lista",
|
|
39
|
+
icon=ft.Icons.CLEAR_ALL,
|
|
40
|
+
disabled=True,
|
|
41
|
+
on_click=self._clear_all,
|
|
42
|
+
)
|
|
43
|
+
self._pick_btn = ft.FilledTonalButton(
|
|
44
|
+
self.pick_label,
|
|
45
|
+
icon=self.pick_icon,
|
|
46
|
+
tooltip=f"{self.pick_label} ({shortcut_label('O')})",
|
|
47
|
+
on_click=lambda _e: self._picker.pick_files(
|
|
48
|
+
allow_multiple=True,
|
|
49
|
+
allowed_extensions=list(self.allowed_extensions),
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
return ft.Row([self._pick_btn, self._clear_btn])
|
|
53
|
+
|
|
54
|
+
def _lock_inputs(self, locked: bool) -> None:
|
|
55
|
+
self._pick_btn.disabled = locked
|
|
56
|
+
if hasattr(self, "_file_list"):
|
|
57
|
+
self._refresh()
|
|
58
|
+
|
|
59
|
+
def build_body(self) -> ft.Control:
|
|
60
|
+
# La lista rellena el cuerpo y scrollea sola cuando no cabe; el footer
|
|
61
|
+
# (ejecutar, progreso, status) queda siempre visible.
|
|
62
|
+
# ListView construye solo las filas visibles: con cientos de archivos
|
|
63
|
+
# el scroll sigue siendo fluido.
|
|
64
|
+
self._file_list = ft.ListView(spacing=4, expand=True)
|
|
65
|
+
return self._file_list
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _thumb_image(png: bytes) -> ft.Image:
|
|
69
|
+
return ft.Image(
|
|
70
|
+
src_base64=base64.b64encode(png).decode(),
|
|
71
|
+
height=THUMBNAIL_HEIGHT_PX,
|
|
72
|
+
fit=ft.ImageFit.CONTAIN,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _thumb_control(self, path: Path) -> ft.Container:
|
|
76
|
+
cached = get_cached(path)
|
|
77
|
+
if cached is not MISSING and cached is not None:
|
|
78
|
+
content: ft.Control = self._thumb_image(cached)
|
|
79
|
+
else:
|
|
80
|
+
icon = (
|
|
81
|
+
ft.Icons.PICTURE_AS_PDF
|
|
82
|
+
if path.suffix.lower() == ".pdf"
|
|
83
|
+
else ft.Icons.IMAGE
|
|
84
|
+
)
|
|
85
|
+
content = ft.Icon(icon, size=28, color=ft.Colors.ON_SURFACE_VARIANT)
|
|
86
|
+
box = ft.Container(
|
|
87
|
+
content=content,
|
|
88
|
+
height=THUMBNAIL_HEIGHT_PX,
|
|
89
|
+
width=44,
|
|
90
|
+
alignment=ft.alignment.center,
|
|
91
|
+
)
|
|
92
|
+
self._thumb_boxes[str(path)] = box
|
|
93
|
+
return box
|
|
94
|
+
|
|
95
|
+
def _on_thumb_ready(self, path: Path, png: bytes | None) -> None:
|
|
96
|
+
# Llega desde el hilo del loader; si la fila ya no existe o el render
|
|
97
|
+
# falló (None), el placeholder/icono se queda como está.
|
|
98
|
+
box = self._thumb_boxes.get(str(path))
|
|
99
|
+
if box is None or png is None:
|
|
100
|
+
return
|
|
101
|
+
box.content = self._thumb_image(png)
|
|
102
|
+
# Sin page.update aquí: el loader llama a on_flush por lotes.
|
|
103
|
+
|
|
104
|
+
def _refresh(self) -> None:
|
|
105
|
+
if self._thumb_task is not None:
|
|
106
|
+
self._thumb_task.cancel()
|
|
107
|
+
self._thumb_task = None
|
|
108
|
+
self._thumb_generation += 1
|
|
109
|
+
generation = self._thumb_generation
|
|
110
|
+
self._thumb_boxes = {}
|
|
111
|
+
self._file_list.controls.clear()
|
|
112
|
+
for index, path in enumerate(self._files):
|
|
113
|
+
result = self._results[index] if index < len(self._results) else None
|
|
114
|
+
row_path = self._row_paths[index] if index < len(self._row_paths) else None
|
|
115
|
+
failed = index < len(self._failed) and self._failed[index]
|
|
116
|
+
warnings = (
|
|
117
|
+
self._row_warnings[index] if index < len(self._row_warnings) else ()
|
|
118
|
+
)
|
|
119
|
+
controls: list[ft.Control] = [ft.Text(f"{index + 1}.", width=28)]
|
|
120
|
+
if self.show_thumbnails:
|
|
121
|
+
controls.append(self._thumb_control(path))
|
|
122
|
+
controls += [
|
|
123
|
+
ft.Text(path.name, expand=True, overflow=ft.TextOverflow.ELLIPSIS),
|
|
124
|
+
ft.Text(
|
|
125
|
+
result or "",
|
|
126
|
+
size=14,
|
|
127
|
+
no_wrap=True,
|
|
128
|
+
color=ft.Colors.ERROR if failed else ft.Colors.ON_SURFACE_VARIANT,
|
|
129
|
+
),
|
|
130
|
+
]
|
|
131
|
+
if warnings:
|
|
132
|
+
text = " ".join(warnings)
|
|
133
|
+
controls.append(
|
|
134
|
+
ft.Icon(
|
|
135
|
+
ft.Icons.WARNING_AMBER_ROUNDED,
|
|
136
|
+
size=20,
|
|
137
|
+
color=ft.Colors.TERTIARY,
|
|
138
|
+
tooltip=text,
|
|
139
|
+
semantics_label=f"Aviso: {text}",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
controls += [
|
|
143
|
+
NamedIconButton(
|
|
144
|
+
ft.Icons.OPEN_IN_NEW,
|
|
145
|
+
f"Abrir resultado de {path.name}",
|
|
146
|
+
visible=row_path is not None,
|
|
147
|
+
on_click=lambda _e, p=row_path: open_file(p),
|
|
148
|
+
),
|
|
149
|
+
]
|
|
150
|
+
if self.reorderable:
|
|
151
|
+
controls += [
|
|
152
|
+
NamedIconButton(
|
|
153
|
+
ft.Icons.ARROW_UPWARD,
|
|
154
|
+
f"Subir {path.name}",
|
|
155
|
+
disabled=index == 0 or self._running,
|
|
156
|
+
on_click=lambda _e, i=index: self._move(i, -1),
|
|
157
|
+
),
|
|
158
|
+
NamedIconButton(
|
|
159
|
+
ft.Icons.ARROW_DOWNWARD,
|
|
160
|
+
f"Bajar {path.name}",
|
|
161
|
+
disabled=index == len(self._files) - 1 or self._running,
|
|
162
|
+
on_click=lambda _e, i=index: self._move(i, 1),
|
|
163
|
+
),
|
|
164
|
+
]
|
|
165
|
+
controls += [
|
|
166
|
+
NamedIconButton(
|
|
167
|
+
ft.Icons.CLOSE,
|
|
168
|
+
f"Quitar {path.name} de la lista",
|
|
169
|
+
disabled=self._running,
|
|
170
|
+
on_click=lambda _e, i=index: self._remove(i),
|
|
171
|
+
),
|
|
172
|
+
]
|
|
173
|
+
self._file_list.controls.append(
|
|
174
|
+
ft.Row(controls, alignment=ft.MainAxisAlignment.SPACE_BETWEEN)
|
|
175
|
+
)
|
|
176
|
+
if self.show_thumbnails:
|
|
177
|
+
pending = [p for p in self._files if get_cached(p) is MISSING]
|
|
178
|
+
if pending:
|
|
179
|
+
self._thumb_task = load_async(
|
|
180
|
+
pending,
|
|
181
|
+
self._on_thumb_ready,
|
|
182
|
+
is_current=lambda: self._thumb_generation == generation,
|
|
183
|
+
on_flush=self._page.update,
|
|
184
|
+
)
|
|
185
|
+
self.on_inputs_changed()
|
|
186
|
+
self._sync_ready_state()
|
|
187
|
+
self._clear_btn.disabled = not self._files or self._running
|
|
188
|
+
n = len(self._files)
|
|
189
|
+
self._counter.value = (
|
|
190
|
+
"" if n == 0 else "1 archivo" if n == 1 else f"{n} archivos"
|
|
191
|
+
)
|
|
192
|
+
self._page.update()
|
|
193
|
+
|
|
194
|
+
def _clear_results(self) -> None:
|
|
195
|
+
self._results = []
|
|
196
|
+
self._row_paths = []
|
|
197
|
+
self._failed = []
|
|
198
|
+
self._row_warnings = []
|
|
199
|
+
|
|
200
|
+
def _move(self, index: int, delta: int) -> None:
|
|
201
|
+
new_index = index + delta
|
|
202
|
+
if 0 <= new_index < len(self._files):
|
|
203
|
+
self._invalidate_active_job()
|
|
204
|
+
self._files[index], self._files[new_index] = (
|
|
205
|
+
self._files[new_index],
|
|
206
|
+
self._files[index],
|
|
207
|
+
)
|
|
208
|
+
self._clear_results()
|
|
209
|
+
self._refresh()
|
|
210
|
+
|
|
211
|
+
def _remove(self, index: int) -> None:
|
|
212
|
+
self._invalidate_active_job()
|
|
213
|
+
self._files.pop(index)
|
|
214
|
+
self._clear_results()
|
|
215
|
+
self._refresh()
|
|
216
|
+
|
|
217
|
+
def _clear_all(self, _e) -> None:
|
|
218
|
+
self._invalidate_active_job()
|
|
219
|
+
removed = list(self._files)
|
|
220
|
+
self._files = []
|
|
221
|
+
self._clear_results()
|
|
222
|
+
self._clear_error()
|
|
223
|
+
self._set_status("")
|
|
224
|
+
self._hide_result_actions()
|
|
225
|
+
self._refresh()
|
|
226
|
+
if removed:
|
|
227
|
+
self._notify(
|
|
228
|
+
"Lista vaciada.",
|
|
229
|
+
action="Deshacer",
|
|
230
|
+
on_action=lambda _e: self._restore(removed),
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def _restore(self, files: list[Path]) -> None:
|
|
234
|
+
"""Deshace «Limpiar lista» si el usuario no añadió nada mientras."""
|
|
235
|
+
if self._files:
|
|
236
|
+
return
|
|
237
|
+
self._files = list(files)
|
|
238
|
+
self._refresh()
|
|
239
|
+
|
|
240
|
+
def _on_pick(self, e) -> None:
|
|
241
|
+
if not e.files:
|
|
242
|
+
return
|
|
243
|
+
self._invalidate_active_job()
|
|
244
|
+
added = 0
|
|
245
|
+
known = set(self._files) # O(1) por archivo, también con miles
|
|
246
|
+
for f in e.files:
|
|
247
|
+
if not f.path: # navegador (modo web): sin ruta local
|
|
248
|
+
continue
|
|
249
|
+
path = Path(f.path)
|
|
250
|
+
if path not in known:
|
|
251
|
+
known.add(path)
|
|
252
|
+
self._files.append(path)
|
|
253
|
+
added += 1
|
|
254
|
+
if added == 0 and e.files:
|
|
255
|
+
self._set_status(WEB_MODE_MSG)
|
|
256
|
+
else:
|
|
257
|
+
self._hide_result_actions()
|
|
258
|
+
self._set_status("")
|
|
259
|
+
self._clear_error()
|
|
260
|
+
self._clear_results()
|
|
261
|
+
self._refresh()
|
|
262
|
+
|
|
263
|
+
def on_result(self, result: ToolResult) -> None:
|
|
264
|
+
items = result.items or []
|
|
265
|
+
self._results = [item.message for item in items]
|
|
266
|
+
self._row_paths = [item.output_path if item.ok else None for item in items]
|
|
267
|
+
self._failed = [not item.ok for item in items]
|
|
268
|
+
self._row_warnings = [item.warnings for item in items]
|
|
269
|
+
self._refresh()
|
|
270
|
+
|
|
271
|
+
def collect_inputs(self) -> list[Path]:
|
|
272
|
+
return list(self._files)
|
|
273
|
+
|
|
274
|
+
def can_run(self) -> bool:
|
|
275
|
+
return len(self._files) >= self.min_files
|
pdftool/ui/logs.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import flet as ft
|
|
7
|
+
|
|
8
|
+
from pdftool.core.logger import log_paths
|
|
9
|
+
|
|
10
|
+
_EMPTY = "(registro vacío)\n"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _log_content() -> str:
|
|
14
|
+
chunks: list[str] = []
|
|
15
|
+
for p in log_paths():
|
|
16
|
+
try:
|
|
17
|
+
chunks.append(p.read_text(encoding="utf-8"))
|
|
18
|
+
except OSError: # rotación entre exists() y read_text(): archivo ilegible
|
|
19
|
+
continue
|
|
20
|
+
return "".join(chunks) if chunks else _EMPTY
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
LOG_LABEL = "Guardar informe de diagnóstico"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_log_picker(
|
|
27
|
+
on_saved: Callable[[Path], None] | None = None,
|
|
28
|
+
) -> ft.FilePicker:
|
|
29
|
+
"""FilePicker de "guardar como" que escribe el log en la ruta elegida.
|
|
30
|
+
|
|
31
|
+
El llamador debe añadirlo a page.overlay (una sola vez). `on_saved` recibe
|
|
32
|
+
la ruta final para confirmar al usuario dónde quedó el informe.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def on_save(e: ft.FilePickerResultEvent) -> None:
|
|
36
|
+
if not e.path: # diálogo cancelado (o modo web sin ruta)
|
|
37
|
+
return
|
|
38
|
+
target = Path(e.path)
|
|
39
|
+
if target.suffix.lower() != ".txt":
|
|
40
|
+
target = target.with_suffix(".txt")
|
|
41
|
+
target.write_text(_log_content(), encoding="utf-8")
|
|
42
|
+
if on_saved is not None:
|
|
43
|
+
on_saved(target)
|
|
44
|
+
|
|
45
|
+
return ft.FilePicker(on_result=on_save)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def download_log_button(picker: ft.FilePicker) -> ft.Control:
|
|
49
|
+
"""Botón del informe de diagnóstico: abre el diálogo de guardado."""
|
|
50
|
+
return ft.TextButton(
|
|
51
|
+
LOG_LABEL,
|
|
52
|
+
icon=ft.Icons.DOWNLOAD,
|
|
53
|
+
on_click=lambda _e: picker.save_file(
|
|
54
|
+
dialog_title=LOG_LABEL,
|
|
55
|
+
file_name="pdf-tool-log.txt",
|
|
56
|
+
allowed_extensions=["txt"],
|
|
57
|
+
),
|
|
58
|
+
)
|
pdftool/ui/output_dir.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import flet as ft
|
|
7
|
+
|
|
8
|
+
from pdftool.core.config import Settings, save_settings
|
|
9
|
+
from pdftool.ui.accessibility import NamedIconButton
|
|
10
|
+
|
|
11
|
+
_DEFAULT_LABEL = "Junto al original"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def abbreviate_home(path: Path) -> str:
|
|
15
|
+
"""Ruta legible: «~/Desktop» en vez de «/Users/quien/Desktop»."""
|
|
16
|
+
try:
|
|
17
|
+
rel = path.relative_to(Path.home())
|
|
18
|
+
except ValueError:
|
|
19
|
+
return str(path)
|
|
20
|
+
return "~" if rel == Path(".") else f"~/{rel}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OutputDirField(ft.Row):
|
|
24
|
+
"""Selector del destino de las salidas: junto al original o una carpeta.
|
|
25
|
+
|
|
26
|
+
El estado es global y vive en `Settings`; se persiste en cuanto cambia, no
|
|
27
|
+
al ejecutar. Si la carpeta guardada ya no existe (disco desconectado,
|
|
28
|
+
carpeta borrada), se vuelve al default en silencio.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
settings: Settings | None,
|
|
34
|
+
on_change: Callable[[], None] | None = None,
|
|
35
|
+
settings_path: Path | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
# Sin Settings real (p. ej. tests que no pasan ctx.settings), el
|
|
38
|
+
# widget usa una privada solo para no romper pero nunca persiste:
|
|
39
|
+
# de lo contrario `set_dir` escribiría en el settings.json real.
|
|
40
|
+
self._persist = settings is not None
|
|
41
|
+
self._settings = settings if settings is not None else Settings()
|
|
42
|
+
self._on_change = on_change
|
|
43
|
+
# Público a propósito: los tests lo redirigen a un tmp_path para no
|
|
44
|
+
# escribir en el settings.json real del usuario. None → el de la app.
|
|
45
|
+
self.settings_path = settings_path
|
|
46
|
+
self._dir = self._restore()
|
|
47
|
+
|
|
48
|
+
self.label = ft.Text(
|
|
49
|
+
_DEFAULT_LABEL, max_lines=1, overflow=ft.TextOverflow.ELLIPSIS, expand=True
|
|
50
|
+
)
|
|
51
|
+
self.change_btn = ft.TextButton(
|
|
52
|
+
"Cambiar…", icon=ft.Icons.FOLDER_OPEN, on_click=self._pick
|
|
53
|
+
)
|
|
54
|
+
self.reset_btn = NamedIconButton(
|
|
55
|
+
ft.Icons.UNDO,
|
|
56
|
+
"Volver a «junto al original»",
|
|
57
|
+
on_click=lambda _e: self.set_dir(None),
|
|
58
|
+
)
|
|
59
|
+
self._picker = ft.FilePicker(on_result=self._on_pick_result)
|
|
60
|
+
|
|
61
|
+
super().__init__(
|
|
62
|
+
[ft.Text("Guardar en:"), self.label, self.change_btn, self.reset_btn]
|
|
63
|
+
)
|
|
64
|
+
self._render()
|
|
65
|
+
|
|
66
|
+
# ---- estado ----
|
|
67
|
+
@property
|
|
68
|
+
def value(self) -> Path | None:
|
|
69
|
+
return self._dir
|
|
70
|
+
|
|
71
|
+
def set_dir(self, path: Path | None) -> None:
|
|
72
|
+
"""Fija el destino, lo persiste y avisa al panel."""
|
|
73
|
+
self._dir = path
|
|
74
|
+
self._settings.output_dir = str(path) if path is not None else None
|
|
75
|
+
if self._persist:
|
|
76
|
+
save_settings(self._settings, self.settings_path)
|
|
77
|
+
self._render()
|
|
78
|
+
if self.page:
|
|
79
|
+
self.update()
|
|
80
|
+
if self._on_change:
|
|
81
|
+
self._on_change()
|
|
82
|
+
|
|
83
|
+
def attach(self, page) -> None:
|
|
84
|
+
"""Registra el picker en el overlay una sola vez."""
|
|
85
|
+
if self._picker not in page.overlay:
|
|
86
|
+
page.overlay.append(self._picker)
|
|
87
|
+
|
|
88
|
+
def sync(self) -> None:
|
|
89
|
+
"""Re-lee el destino de `Settings`. Lo llama el panel en cada render,
|
|
90
|
+
porque el widget sobrevive entre navegaciones y otro panel puede haber
|
|
91
|
+
cambiado el destino mientras tanto."""
|
|
92
|
+
self._dir = self._restore()
|
|
93
|
+
self._render()
|
|
94
|
+
if self.page:
|
|
95
|
+
self.update()
|
|
96
|
+
|
|
97
|
+
def destination_missing(self) -> bool:
|
|
98
|
+
"""True si hay un destino elegido y ya no es una carpeta accesible."""
|
|
99
|
+
return self._dir is not None and not self._dir.is_dir()
|
|
100
|
+
|
|
101
|
+
# ---- interno ----
|
|
102
|
+
def _restore(self) -> Path | None:
|
|
103
|
+
guardado = self._settings.output_dir
|
|
104
|
+
if not guardado:
|
|
105
|
+
return None
|
|
106
|
+
path = Path(guardado)
|
|
107
|
+
if path.is_dir():
|
|
108
|
+
return path
|
|
109
|
+
# La carpeta ya no está: al default, sin molestar al usuario.
|
|
110
|
+
self._settings.output_dir = None
|
|
111
|
+
if self._persist:
|
|
112
|
+
save_settings(self._settings, self.settings_path)
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def _render(self) -> None:
|
|
116
|
+
self.label.value = (
|
|
117
|
+
_DEFAULT_LABEL if self._dir is None else abbreviate_home(self._dir)
|
|
118
|
+
)
|
|
119
|
+
self.label.tooltip = None if self._dir is None else str(self._dir)
|
|
120
|
+
self.reset_btn.visible = self._dir is not None
|
|
121
|
+
|
|
122
|
+
def _pick(self, _e) -> None:
|
|
123
|
+
self._picker.get_directory_path(dialog_title="Guardar las salidas en…")
|
|
124
|
+
|
|
125
|
+
def _on_pick_result(self, e) -> None:
|
|
126
|
+
if e.path:
|
|
127
|
+
self.set_dir(Path(e.path))
|