NotYAFC 1.0.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.
- font_converter/__init__.py +4 -0
- font_converter/__main__.py +45 -0
- font_converter/assets/ico.png +0 -0
- font_converter/assets/notyafc-1024.png +0 -0
- font_converter/assets/notyafc.ico +0 -0
- font_converter/assets/notyafc.svg +15 -0
- font_converter/cli.py +543 -0
- font_converter/constants.py +27 -0
- font_converter/contours.py +336 -0
- font_converter/css_kit.py +77 -0
- font_converter/finder.py +213 -0
- font_converter/font.py +530 -0
- font_converter/gui/__init__.py +1 -0
- font_converter/gui/conversion_dialog.py +110 -0
- font_converter/gui/file_list.py +411 -0
- font_converter/gui/instance_tree.py +583 -0
- font_converter/gui/logs.py +51 -0
- font_converter/gui/organization_dialog.py +417 -0
- font_converter/gui/profile_options.py +316 -0
- font_converter/gui/profiles.py +17 -0
- font_converter/gui/scanner.py +42 -0
- font_converter/gui/styles.py +658 -0
- font_converter/gui/widgets.py +495 -0
- font_converter/gui/window.py +1768 -0
- font_converter/inspection.py +169 -0
- font_converter/merge_curves.py +499 -0
- font_converter/otf_builder.py +144 -0
- font_converter/qu2cu.py +117 -0
- font_converter/service.py +729 -0
- font_converter/ttf_builder.py +107 -0
- notyafc-1.0.0.dist-info/METADATA +256 -0
- notyafc-1.0.0.dist-info/RECORD +34 -0
- notyafc-1.0.0.dist-info/WHEEL +4 -0
- notyafc-1.0.0.dist-info/entry_points.txt +6 -0
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
"""Native NotYAFC converter window."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import (
|
|
10
|
+
QEvent,
|
|
11
|
+
QObject,
|
|
12
|
+
QPoint,
|
|
13
|
+
QSettings,
|
|
14
|
+
QSize,
|
|
15
|
+
Qt,
|
|
16
|
+
QThread,
|
|
17
|
+
QTimer,
|
|
18
|
+
QUrl,
|
|
19
|
+
Signal,
|
|
20
|
+
)
|
|
21
|
+
from PySide6.QtGui import (
|
|
22
|
+
QAction,
|
|
23
|
+
QCloseEvent,
|
|
24
|
+
QDesktopServices,
|
|
25
|
+
QGuiApplication,
|
|
26
|
+
QKeySequence,
|
|
27
|
+
QResizeEvent,
|
|
28
|
+
QShortcut,
|
|
29
|
+
QShowEvent,
|
|
30
|
+
)
|
|
31
|
+
from PySide6.QtWidgets import (
|
|
32
|
+
QAbstractButton,
|
|
33
|
+
QBoxLayout,
|
|
34
|
+
QButtonGroup,
|
|
35
|
+
QCheckBox,
|
|
36
|
+
QDialog,
|
|
37
|
+
QDialogButtonBox,
|
|
38
|
+
QFileDialog,
|
|
39
|
+
QGroupBox,
|
|
40
|
+
QHBoxLayout,
|
|
41
|
+
QLabel,
|
|
42
|
+
QLineEdit,
|
|
43
|
+
QMainWindow,
|
|
44
|
+
QMenu,
|
|
45
|
+
QMessageBox,
|
|
46
|
+
QProgressBar,
|
|
47
|
+
QRadioButton,
|
|
48
|
+
QScrollArea,
|
|
49
|
+
QSizePolicy,
|
|
50
|
+
QStackedWidget,
|
|
51
|
+
QStyle,
|
|
52
|
+
QVBoxLayout,
|
|
53
|
+
QWidget,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
from font_converter.gui.conversion_dialog import ConversionDialog
|
|
57
|
+
from font_converter.gui.file_list import FontFileList
|
|
58
|
+
from font_converter.gui.instance_tree import InstanceTree
|
|
59
|
+
from font_converter.gui.logs import LogEntry, LogLevel
|
|
60
|
+
from font_converter.gui.organization_dialog import (
|
|
61
|
+
CRITERIA,
|
|
62
|
+
OrganizationDialog,
|
|
63
|
+
available_organization_criteria,
|
|
64
|
+
)
|
|
65
|
+
from font_converter.gui.profile_options import ProfileOptionsPanel
|
|
66
|
+
from font_converter.gui.profiles import CONVERSION_GROUP_HEADINGS, CONVERSIONS
|
|
67
|
+
from font_converter.gui.styles import stylesheet_for
|
|
68
|
+
from font_converter.gui.widgets import (
|
|
69
|
+
ConversionSelector,
|
|
70
|
+
DropZone,
|
|
71
|
+
ElidedLabel,
|
|
72
|
+
FileTable,
|
|
73
|
+
KeyboardPushButton,
|
|
74
|
+
ThemeToggle,
|
|
75
|
+
)
|
|
76
|
+
from font_converter.inspection import FontInfo, incompatibility
|
|
77
|
+
from font_converter.service import (
|
|
78
|
+
ConversionOptions,
|
|
79
|
+
ConversionRequest,
|
|
80
|
+
ConversionResult,
|
|
81
|
+
ConversionSettings,
|
|
82
|
+
ConverterService,
|
|
83
|
+
FontOutcome,
|
|
84
|
+
FontOutcomeKind,
|
|
85
|
+
organization_level_name,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
FONT_FILTER = "Font files (*.ttf *.otf *.woff *.woff2 *.ttc *.otc);;All files (*)"
|
|
89
|
+
INPUT_DIR_KEY = "last_input_dir"
|
|
90
|
+
OUTPUT_DIR_KEY = "last_output_dir"
|
|
91
|
+
LOG_DIR_KEY = "last_log_dir"
|
|
92
|
+
FONT_FILTER_PLACEHOLDER = "Filter fonts by name, family, style, format, outlines, or UPM…"
|
|
93
|
+
OPTIONAL_FONT_COLUMNS = {4: ("Family", "font_columns/family"), 5: ("Style", "font_columns/style")}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _window_sizes(available: QSize | None) -> tuple[QSize, QSize]:
|
|
97
|
+
minimum = QSize(800, 500)
|
|
98
|
+
preferred = QSize(1200, 880)
|
|
99
|
+
if available is None:
|
|
100
|
+
return minimum, preferred
|
|
101
|
+
usable = QSize(max(1, available.width() - 40), max(1, available.height() - 80))
|
|
102
|
+
return minimum.boundedTo(usable), preferred.boundedTo(usable)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def format_duration(seconds: float) -> str:
|
|
106
|
+
if seconds < 60:
|
|
107
|
+
return f"{seconds:.1f} s"
|
|
108
|
+
minutes, secs = divmod(int(seconds), 60)
|
|
109
|
+
hours, minutes = divmod(minutes, 60)
|
|
110
|
+
return f"{hours}:{minutes:02d}:{secs:02d}" if hours else f"{minutes}:{secs:02d}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _exception_type(message: str) -> str | None:
|
|
114
|
+
name, separator, _ = message.partition(": ")
|
|
115
|
+
return name if separator and name.isidentifier() else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class RunState(Enum):
|
|
119
|
+
IDLE = "idle"
|
|
120
|
+
RUNNING = "running"
|
|
121
|
+
PAUSING = "pausing"
|
|
122
|
+
PAUSED = "paused"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ConversionWorker(QThread):
|
|
126
|
+
log_received = Signal(object)
|
|
127
|
+
progress_changed = Signal(int, int)
|
|
128
|
+
finished_conversion = Signal(object)
|
|
129
|
+
failed = Signal(str)
|
|
130
|
+
paused_changed = Signal(bool)
|
|
131
|
+
|
|
132
|
+
def __init__(self, request: ConversionRequest) -> None:
|
|
133
|
+
super().__init__()
|
|
134
|
+
self.request = request
|
|
135
|
+
self._current_input: Path | None = None
|
|
136
|
+
self._lock = threading.Lock()
|
|
137
|
+
self._running = threading.Event()
|
|
138
|
+
self._running.set()
|
|
139
|
+
|
|
140
|
+
def pause(self) -> None:
|
|
141
|
+
with self._lock:
|
|
142
|
+
if not self.isInterruptionRequested():
|
|
143
|
+
self._running.clear()
|
|
144
|
+
|
|
145
|
+
def resume(self) -> None:
|
|
146
|
+
with self._lock:
|
|
147
|
+
self._running.set()
|
|
148
|
+
|
|
149
|
+
def cancel(self) -> None:
|
|
150
|
+
with self._lock:
|
|
151
|
+
self.requestInterruption()
|
|
152
|
+
self._running.set()
|
|
153
|
+
|
|
154
|
+
def wait_if_paused(self) -> None:
|
|
155
|
+
if self._running.is_set():
|
|
156
|
+
return
|
|
157
|
+
self.paused_changed.emit(True)
|
|
158
|
+
self._running.wait()
|
|
159
|
+
self.paused_changed.emit(False)
|
|
160
|
+
|
|
161
|
+
def run(self) -> None:
|
|
162
|
+
try:
|
|
163
|
+
result = ConverterService().convert(
|
|
164
|
+
self.request,
|
|
165
|
+
on_log=lambda message: self._log(
|
|
166
|
+
"INFO",
|
|
167
|
+
"conversion.step",
|
|
168
|
+
message,
|
|
169
|
+
{
|
|
170
|
+
"profile": self.request.conversion,
|
|
171
|
+
"input": (
|
|
172
|
+
self._current_input.name if self._current_input is not None else None
|
|
173
|
+
),
|
|
174
|
+
},
|
|
175
|
+
),
|
|
176
|
+
on_outcome=self._report_outcome,
|
|
177
|
+
on_progress=lambda progress: self.progress_changed.emit(
|
|
178
|
+
progress.done, progress.total
|
|
179
|
+
),
|
|
180
|
+
should_cancel=self.isInterruptionRequested,
|
|
181
|
+
on_font_start=self._on_font_start,
|
|
182
|
+
wait_if_paused=self.wait_if_paused,
|
|
183
|
+
)
|
|
184
|
+
self.finished_conversion.emit(result)
|
|
185
|
+
except Exception as error:
|
|
186
|
+
self.failed.emit(f"{type(error).__name__}: {error}")
|
|
187
|
+
|
|
188
|
+
def _on_font_start(self, input_path: Path) -> None:
|
|
189
|
+
self._current_input = input_path
|
|
190
|
+
|
|
191
|
+
def _report_outcome(self, outcome: FontOutcome) -> None:
|
|
192
|
+
duration = format_duration(outcome.elapsed)
|
|
193
|
+
name = outcome.input_path.name
|
|
194
|
+
if self.request.conversion == "var2static" and outcome.output_paths:
|
|
195
|
+
name = f"{name} → {outcome.output_paths[0].name}"
|
|
196
|
+
if outcome.kind is FontOutcomeKind.CONVERTED:
|
|
197
|
+
self._log(
|
|
198
|
+
"INFO",
|
|
199
|
+
"font.converted",
|
|
200
|
+
f"Converted {name} in {duration}",
|
|
201
|
+
{
|
|
202
|
+
"input": outcome.input_path.name,
|
|
203
|
+
"outputs": [path.name for path in outcome.output_paths],
|
|
204
|
+
"elapsed_seconds": outcome.elapsed,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
elif outcome.kind is FontOutcomeKind.SKIPPED:
|
|
208
|
+
self._log(
|
|
209
|
+
"INFO",
|
|
210
|
+
"font.skipped",
|
|
211
|
+
f"Skipped {name}",
|
|
212
|
+
{"input": outcome.input_path.name},
|
|
213
|
+
)
|
|
214
|
+
elif outcome.error is not None:
|
|
215
|
+
self._log(
|
|
216
|
+
"ERROR",
|
|
217
|
+
"font.failed",
|
|
218
|
+
f"{outcome.error} ({name}, {duration})",
|
|
219
|
+
{
|
|
220
|
+
"input": outcome.input_path.name,
|
|
221
|
+
"elapsed_seconds": outcome.elapsed,
|
|
222
|
+
"error_type": _exception_type(outcome.error),
|
|
223
|
+
"error": outcome.error,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def _log(
|
|
228
|
+
self,
|
|
229
|
+
level: LogLevel,
|
|
230
|
+
event: str,
|
|
231
|
+
message: str,
|
|
232
|
+
context: dict[str, object] | None = None,
|
|
233
|
+
) -> None:
|
|
234
|
+
self.log_received.emit(LogEntry.create(level, event, message, context))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class ConverterWindow(QMainWindow):
|
|
238
|
+
def __init__(self) -> None:
|
|
239
|
+
super().__init__()
|
|
240
|
+
self.setWindowTitle("NotYAFC")
|
|
241
|
+
screen = self.screen()
|
|
242
|
+
available = screen.availableGeometry().size() if screen is not None else None
|
|
243
|
+
minimum, initial = _window_sizes(available)
|
|
244
|
+
self.setMinimumSize(minimum)
|
|
245
|
+
self.resize(initial)
|
|
246
|
+
self.worker: ConversionWorker | None = None
|
|
247
|
+
self.output_directory: Path | None = None
|
|
248
|
+
self.organization_levels: tuple[tuple[str, ...], ...] = ()
|
|
249
|
+
self._settings = QSettings()
|
|
250
|
+
self._close_when_finished = False
|
|
251
|
+
self._saved_files: list[Path] = []
|
|
252
|
+
self._run_output_directory: Path | None = None
|
|
253
|
+
self._run_input_directory: Path | None = None
|
|
254
|
+
self._last_run_available = False
|
|
255
|
+
self._sources: list[Path] = []
|
|
256
|
+
self._scan_cancelled = False
|
|
257
|
+
self._input_check_states: dict[Path, bool] = {}
|
|
258
|
+
self._log_entries: list[LogEntry] = []
|
|
259
|
+
self._progress_done = 0
|
|
260
|
+
self._progress_total = 0
|
|
261
|
+
self._session_started_at: float | None = None
|
|
262
|
+
self._session_elapsed = 0.0
|
|
263
|
+
self._cancelling = False
|
|
264
|
+
self._run_state = RunState.IDLE
|
|
265
|
+
self._profile_options_width = 0
|
|
266
|
+
self._elapsed_timer = QTimer(self)
|
|
267
|
+
self._elapsed_timer.setInterval(1000)
|
|
268
|
+
self._elapsed_timer.timeout.connect(self._refresh_progress_status)
|
|
269
|
+
|
|
270
|
+
page = QWidget()
|
|
271
|
+
page.setObjectName("page")
|
|
272
|
+
layout = QVBoxLayout(page)
|
|
273
|
+
layout.setContentsMargins(26, 12, 26, 12)
|
|
274
|
+
layout.setSpacing(8)
|
|
275
|
+
|
|
276
|
+
title = QLabel("NotYAFC")
|
|
277
|
+
title.setObjectName("pageTitle")
|
|
278
|
+
subtitle = ElidedLabel("Not Yet Another Font Converter")
|
|
279
|
+
subtitle.setObjectName("pageSubtitle")
|
|
280
|
+
subtitle.setToolTip(subtitle.text())
|
|
281
|
+
subtitle.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
|
282
|
+
self.theme_toggle = ThemeToggle()
|
|
283
|
+
title_row = QHBoxLayout()
|
|
284
|
+
title_row.addWidget(title)
|
|
285
|
+
title_row.addWidget(subtitle, 1)
|
|
286
|
+
title_row.addWidget(self.theme_toggle)
|
|
287
|
+
layout.addLayout(title_row)
|
|
288
|
+
|
|
289
|
+
self.conversion = ConversionSelector(
|
|
290
|
+
CONVERSIONS, select_first=False, group_headings=CONVERSION_GROUP_HEADINGS
|
|
291
|
+
)
|
|
292
|
+
conversion_layout = QVBoxLayout()
|
|
293
|
+
conversion_layout.setContentsMargins(16, 12, 16, 14)
|
|
294
|
+
conversion_layout.addWidget(self.conversion)
|
|
295
|
+
self.conversion_group = QGroupBox("2. Choose a conversion")
|
|
296
|
+
self.conversion_group.setObjectName("card")
|
|
297
|
+
self.conversion_group.setLayout(conversion_layout)
|
|
298
|
+
self.conversion_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
299
|
+
self.profile_options = ProfileOptionsPanel()
|
|
300
|
+
self.profile_options.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
301
|
+
self.conversion.currentIndexChanged.connect(self._apply_compatibility)
|
|
302
|
+
self.conversion.currentIndexChanged.connect(lambda _index: self._on_conversion_changed())
|
|
303
|
+
self.conversion.currentIndexChanged.connect(self._update_buttons)
|
|
304
|
+
self.profile_options.compatibilityChanged.connect(self._apply_compatibility)
|
|
305
|
+
self.profile_options.validityChanged.connect(self._update_buttons)
|
|
306
|
+
|
|
307
|
+
self.overwrite = QCheckBox("Overwrite existing files")
|
|
308
|
+
self.overwrite.setChecked(False)
|
|
309
|
+
self.recalc_bboxes = QCheckBox("Recalculate bounding boxes")
|
|
310
|
+
self.recalc_bboxes.setChecked(True)
|
|
311
|
+
self.recalc_bboxes.setToolTip("Rebuild glyph bounds before writing output files.")
|
|
312
|
+
self.recalc_timestamp = QCheckBox("Recalculate modified timestamp")
|
|
313
|
+
self.recalc_timestamp.setToolTip("Set a fresh font modification timestamp on save.")
|
|
314
|
+
self.reorder_tables = QCheckBox("Reorder tables on save")
|
|
315
|
+
self.reorder_tables.setChecked(True)
|
|
316
|
+
self.reorder_tables.setToolTip("Use a standard table order for compatibility.")
|
|
317
|
+
self.organization_button = KeyboardPushButton("Organize output…")
|
|
318
|
+
self.organization_button.setToolTip("Choose how converted fonts are grouped into folders.")
|
|
319
|
+
self.organization_button.clicked.connect(self._show_organization_dialog)
|
|
320
|
+
|
|
321
|
+
self.advanced_settings_button = KeyboardPushButton("Save options…")
|
|
322
|
+
self.advanced_settings_button.setToolTip("Configure how output fonts are saved.")
|
|
323
|
+
for button in (self.organization_button, self.advanced_settings_button):
|
|
324
|
+
button.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
|
325
|
+
self.advanced_settings_dialog = QDialog(self)
|
|
326
|
+
self.advanced_settings_dialog.setWindowTitle("Save options")
|
|
327
|
+
advanced_layout = QVBoxLayout(self.advanced_settings_dialog)
|
|
328
|
+
advanced_layout.setContentsMargins(16, 12, 16, 14)
|
|
329
|
+
advanced_layout.setSpacing(9)
|
|
330
|
+
for checkbox in (self.recalc_bboxes, self.recalc_timestamp, self.reorder_tables):
|
|
331
|
+
advanced_layout.addWidget(checkbox)
|
|
332
|
+
advanced_buttons = QDialogButtonBox(
|
|
333
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
334
|
+
)
|
|
335
|
+
advanced_buttons.accepted.connect(self.advanced_settings_dialog.accept)
|
|
336
|
+
advanced_buttons.rejected.connect(self.advanced_settings_dialog.reject)
|
|
337
|
+
advanced_layout.addWidget(advanced_buttons)
|
|
338
|
+
self.advanced_settings_button.clicked.connect(self._show_advanced_settings)
|
|
339
|
+
|
|
340
|
+
self.files_group = QGroupBox("1. Add fonts")
|
|
341
|
+
self.files_group.setObjectName("card")
|
|
342
|
+
files_layout = QVBoxLayout(self.files_group)
|
|
343
|
+
files_layout.setContentsMargins(16, 12, 16, 14)
|
|
344
|
+
files_layout.setSpacing(9)
|
|
345
|
+
self.files_header = QBoxLayout(QBoxLayout.Direction.LeftToRight)
|
|
346
|
+
self.input_buttons = QBoxLayout(QBoxLayout.Direction.LeftToRight)
|
|
347
|
+
self.input_options = QBoxLayout(QBoxLayout.Direction.LeftToRight)
|
|
348
|
+
self.add_button = KeyboardPushButton("Add files…")
|
|
349
|
+
self.add_folder_button = KeyboardPushButton("Add folder…")
|
|
350
|
+
self.clear_button = KeyboardPushButton("Clear list")
|
|
351
|
+
self.input_buttons.addWidget(self.add_button)
|
|
352
|
+
self.recursive = QCheckBox("Recurse subfolders")
|
|
353
|
+
self.recursive.setToolTip("Also look for fonts in subfolders.")
|
|
354
|
+
self.recursive.toggled.connect(self._refresh_folder_contents)
|
|
355
|
+
self.input_buttons.addWidget(self.add_folder_button)
|
|
356
|
+
self.input_options.addWidget(self.recursive)
|
|
357
|
+
self.input_options.addStretch()
|
|
358
|
+
self.input_options.addWidget(self.clear_button)
|
|
359
|
+
self.files_header.addLayout(self.input_buttons)
|
|
360
|
+
self.files_header.addLayout(self.input_options, 1)
|
|
361
|
+
files_layout.addLayout(self.files_header)
|
|
362
|
+
self.select_all_checkbox = QCheckBox("Select compatible fonts")
|
|
363
|
+
self.select_all_checkbox.setTristate(True)
|
|
364
|
+
self.select_all_checkbox.setToolTip("Select or clear all visible compatible fonts.")
|
|
365
|
+
self.select_all_checkbox.clicked.connect(self._toggle_visible_checks)
|
|
366
|
+
self.hide_incompatible = QCheckBox("Hide incompatible")
|
|
367
|
+
self.hide_incompatible.setToolTip(
|
|
368
|
+
"Hide fonts that cannot be used with the selected profile."
|
|
369
|
+
)
|
|
370
|
+
self.hide_incompatible.toggled.connect(self._apply_compatibility)
|
|
371
|
+
self.hide_incompatible.toggled.connect(self._update_buttons)
|
|
372
|
+
self.file_options_row = QBoxLayout(QBoxLayout.Direction.LeftToRight)
|
|
373
|
+
self.file_options_row.setSpacing(12)
|
|
374
|
+
self.file_options_row.addWidget(self.select_all_checkbox)
|
|
375
|
+
self.file_options_row.addWidget(self.hide_incompatible)
|
|
376
|
+
self.file_options_row.addStretch()
|
|
377
|
+
files_layout.addLayout(self.file_options_row)
|
|
378
|
+
self.font_filter = QLineEdit()
|
|
379
|
+
self.font_filter.setPlaceholderText(FONT_FILTER_PLACEHOLDER)
|
|
380
|
+
self.font_filter.setClearButtonEnabled(True)
|
|
381
|
+
self.font_filter.setToolTip("Filter the font list by matching text.")
|
|
382
|
+
self.font_filter.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
|
383
|
+
self.add_instance_button = KeyboardPushButton("Add custom instance…")
|
|
384
|
+
self.add_instance_button.setToolTip("Select a variable font or instance to add one.")
|
|
385
|
+
self.instance_view_button = KeyboardPushButton("View")
|
|
386
|
+
self.instance_view_button.setToolTip("Expand or collapse all variable fonts.")
|
|
387
|
+
self.instance_view_menu = QMenu(self.instance_view_button)
|
|
388
|
+
self.expand_instances_action = self.instance_view_menu.addAction("Expand all")
|
|
389
|
+
self.collapse_instances_action = self.instance_view_menu.addAction("Collapse all")
|
|
390
|
+
self.instance_view_button.setMenu(self.instance_view_menu)
|
|
391
|
+
self.filter_row = QBoxLayout(QBoxLayout.Direction.LeftToRight)
|
|
392
|
+
self.filter_row.setSpacing(9)
|
|
393
|
+
self.filter_row.addWidget(self.font_filter, 1)
|
|
394
|
+
self.filter_row.addWidget(self.add_instance_button)
|
|
395
|
+
self.filter_row.addWidget(self.instance_view_button)
|
|
396
|
+
files_layout.addLayout(self.filter_row)
|
|
397
|
+
|
|
398
|
+
self.files = FileTable()
|
|
399
|
+
self.file_list = FontFileList(self.files, self._compatibility_profile)
|
|
400
|
+
self.column_menu = QMenu(self.files)
|
|
401
|
+
table_header = self.files.horizontalHeader()
|
|
402
|
+
table_header.setToolTip("Click to sort; right-click to choose optional columns.")
|
|
403
|
+
table_header.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
404
|
+
table_header.customContextMenuRequested.connect(self._show_column_menu)
|
|
405
|
+
for column, (name, key) in OPTIONAL_FONT_COLUMNS.items():
|
|
406
|
+
visible = bool(self._settings.value(key, False, type=bool))
|
|
407
|
+
self.files.setColumnHidden(column, not visible)
|
|
408
|
+
action = self.column_menu.addAction(name)
|
|
409
|
+
action.setData(column)
|
|
410
|
+
action.setCheckable(True)
|
|
411
|
+
action.setChecked(visible)
|
|
412
|
+
action.toggled.connect(self._set_column_visible)
|
|
413
|
+
self.file_list.progress.connect(self._on_scan_progress)
|
|
414
|
+
self.file_list.font_found.connect(self._on_font_found)
|
|
415
|
+
self.file_list.scan_finished.connect(self._on_scan_finished)
|
|
416
|
+
self.files.setMinimumHeight(150)
|
|
417
|
+
self.files.setToolTip("Check the fonts to convert. Click a column header to sort.")
|
|
418
|
+
self.file_list.model.dataChanged.connect(self._on_file_item_changed)
|
|
419
|
+
self.instance_tree = InstanceTree()
|
|
420
|
+
self.font_filter.textChanged.connect(self.instance_tree.set_filter)
|
|
421
|
+
self.font_filter.textChanged.connect(self.file_list.proxy.setFilterFixedString)
|
|
422
|
+
self.font_filter.textChanged.connect(self._update_buttons)
|
|
423
|
+
self.instance_tree.setMinimumHeight(150)
|
|
424
|
+
self.instance_tree.setToolTip(
|
|
425
|
+
"Check instances to export. Double-click a custom instance to edit it; "
|
|
426
|
+
"right-click it or press Shift+F10 for actions, or Delete to remove it."
|
|
427
|
+
)
|
|
428
|
+
self.instance_tree.checksChanged.connect(self._update_buttons)
|
|
429
|
+
self.instance_tree.actionsChanged.connect(self._sync_instance_actions)
|
|
430
|
+
self.expand_instances_action.triggered.connect(self.instance_tree.expandAll)
|
|
431
|
+
self.collapse_instances_action.triggered.connect(self.instance_tree.collapseAll)
|
|
432
|
+
self.add_instance_button.clicked.connect(self.instance_tree.add_custom)
|
|
433
|
+
self._sync_instance_actions()
|
|
434
|
+
self.files_stack = QStackedWidget()
|
|
435
|
+
self.files_stack.addWidget(self.files)
|
|
436
|
+
self.files_stack.addWidget(self.instance_tree)
|
|
437
|
+
self.drop_zone = DropZone()
|
|
438
|
+
self.files_stack.addWidget(self.drop_zone)
|
|
439
|
+
self.instance_view_hint = QLabel(
|
|
440
|
+
"The font list is now an instance tree. Expand a font to select its instances."
|
|
441
|
+
)
|
|
442
|
+
self.instance_view_hint.setObjectName("stepHint")
|
|
443
|
+
self.instance_view_hint.setWordWrap(True)
|
|
444
|
+
self.instance_view_hint.hide()
|
|
445
|
+
files_layout.addWidget(self.instance_view_hint)
|
|
446
|
+
files_layout.addWidget(self.files_stack)
|
|
447
|
+
self.scan_widget = QWidget()
|
|
448
|
+
scan_layout = QHBoxLayout(self.scan_widget)
|
|
449
|
+
scan_layout.setContentsMargins(0, 0, 0, 0)
|
|
450
|
+
scan_layout.setSpacing(10)
|
|
451
|
+
self.scan_label = QLabel()
|
|
452
|
+
self.scan_label.setObjectName("stepHint")
|
|
453
|
+
self.scan_label.setWordWrap(True)
|
|
454
|
+
self.scan_progress = QProgressBar()
|
|
455
|
+
self.scan_progress.setTextVisible(False)
|
|
456
|
+
self.scan_progress.setFixedHeight(8)
|
|
457
|
+
scan_layout.addWidget(self.scan_label)
|
|
458
|
+
scan_layout.addWidget(self.scan_progress, 1)
|
|
459
|
+
self.scan_stop_button = KeyboardPushButton("Stop")
|
|
460
|
+
self.scan_stop_button.setToolTip("Stop the scan and keep the fonts found so far.")
|
|
461
|
+
self.scan_stop_button.clicked.connect(self._cancel_scan)
|
|
462
|
+
scan_layout.addWidget(self.scan_stop_button)
|
|
463
|
+
self.scan_widget.setVisible(False)
|
|
464
|
+
files_layout.addWidget(self.scan_widget)
|
|
465
|
+
self.variable_hint = QLabel()
|
|
466
|
+
self.variable_hint.setObjectName("stepHint")
|
|
467
|
+
self.variable_hint.setWordWrap(True)
|
|
468
|
+
self.variable_hint.hide()
|
|
469
|
+
files_layout.addWidget(self.variable_hint)
|
|
470
|
+
self.destination_group = QGroupBox("3. Choose output")
|
|
471
|
+
self.destination_group.setObjectName("card")
|
|
472
|
+
self.destination_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
|
473
|
+
destination_layout = QVBoxLayout(self.destination_group)
|
|
474
|
+
destination_layout.setContentsMargins(16, 12, 16, 18)
|
|
475
|
+
destination_layout.setSpacing(9)
|
|
476
|
+
self.destination_source_radio = QRadioButton("Same as source")
|
|
477
|
+
self.destination_folder_radio = QRadioButton("Folder")
|
|
478
|
+
self.destination_source_radio.setChecked(True)
|
|
479
|
+
self.destination_mode_group = QButtonGroup(self)
|
|
480
|
+
self.destination_mode_group.addButton(self.destination_source_radio)
|
|
481
|
+
self.destination_mode_group.addButton(self.destination_folder_radio)
|
|
482
|
+
self.destination_edit = QLineEdit()
|
|
483
|
+
self.destination_edit.setReadOnly(True)
|
|
484
|
+
self.destination_edit.setEnabled(False)
|
|
485
|
+
self.destination_button = KeyboardPushButton("…")
|
|
486
|
+
self.destination_button.setObjectName("stepperButton")
|
|
487
|
+
self.destination_button.setToolTip("Choose output folder")
|
|
488
|
+
self.destination_button.setEnabled(False)
|
|
489
|
+
destination = QHBoxLayout()
|
|
490
|
+
destination.setSpacing(8)
|
|
491
|
+
destination.addWidget(self.destination_folder_radio)
|
|
492
|
+
destination.addWidget(self.destination_edit, 1)
|
|
493
|
+
destination.addWidget(self.destination_button)
|
|
494
|
+
destination_layout.addWidget(self.destination_source_radio, 0, Qt.AlignmentFlag.AlignLeft)
|
|
495
|
+
destination_layout.addLayout(destination)
|
|
496
|
+
destination_layout.addWidget(self.overwrite, 0, Qt.AlignmentFlag.AlignLeft)
|
|
497
|
+
options_row = QHBoxLayout()
|
|
498
|
+
options_row.setSpacing(9)
|
|
499
|
+
options_row.addWidget(self.organization_button, 1)
|
|
500
|
+
options_row.addWidget(self.advanced_settings_button, 1)
|
|
501
|
+
destination_layout.addLayout(options_row)
|
|
502
|
+
self.organization_preview = QLabel()
|
|
503
|
+
self.organization_preview.setObjectName("hint")
|
|
504
|
+
self.organization_preview.setTextFormat(Qt.TextFormat.PlainText)
|
|
505
|
+
self.organization_preview.setWordWrap(True)
|
|
506
|
+
self.organization_preview.setSizePolicy(
|
|
507
|
+
QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
|
|
508
|
+
)
|
|
509
|
+
self.organization_preview.setToolTip(
|
|
510
|
+
"Example output path. Actual folder and file names depend on the font."
|
|
511
|
+
)
|
|
512
|
+
self.organization_preview.hide()
|
|
513
|
+
destination_layout.addWidget(self.organization_preview)
|
|
514
|
+
for checkbox in (
|
|
515
|
+
self.profile_options.use_target_upm,
|
|
516
|
+
self.profile_options.format_woff,
|
|
517
|
+
self.profile_options.format_woff2,
|
|
518
|
+
):
|
|
519
|
+
checkbox.toggled.connect(self._update_organization_button)
|
|
520
|
+
for spinbox in (
|
|
521
|
+
self.profile_options.target_upm,
|
|
522
|
+
self.profile_options.tolerance,
|
|
523
|
+
self.profile_options.optimize_contours,
|
|
524
|
+
):
|
|
525
|
+
spinbox.valueChanged.connect(self._update_organization_button)
|
|
526
|
+
sidebar = QWidget()
|
|
527
|
+
sidebar.setObjectName("page")
|
|
528
|
+
sidebar_layout = QVBoxLayout(sidebar)
|
|
529
|
+
sidebar_layout.setContentsMargins(0, 0, 0, 0)
|
|
530
|
+
sidebar_layout.setSpacing(8)
|
|
531
|
+
sidebar_layout.addWidget(self.conversion_group)
|
|
532
|
+
sidebar_layout.addWidget(self.profile_options)
|
|
533
|
+
sidebar_layout.addWidget(self.destination_group)
|
|
534
|
+
sidebar_layout.addStretch()
|
|
535
|
+
self.sidebar_scroll = QScrollArea()
|
|
536
|
+
self.sidebar_scroll.setWidgetResizable(True)
|
|
537
|
+
self.sidebar_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
538
|
+
self.sidebar_scroll.setWidget(sidebar)
|
|
539
|
+
self.sidebar_scroll.setMinimumSize(0, 200)
|
|
540
|
+
self._sidebar_maximum_width = self.sidebar_scroll.maximumWidth()
|
|
541
|
+
self.sidebar_scroll.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Expanding)
|
|
542
|
+
|
|
543
|
+
self.body_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight)
|
|
544
|
+
self.body_layout.setSpacing(12)
|
|
545
|
+
self.body_layout.addWidget(self.files_group, 1)
|
|
546
|
+
self.body_layout.addWidget(self.sidebar_scroll)
|
|
547
|
+
layout.addLayout(self.body_layout, 1)
|
|
548
|
+
|
|
549
|
+
self.convert_button = KeyboardPushButton("Convert")
|
|
550
|
+
self.convert_button.setObjectName("primaryButton")
|
|
551
|
+
self.convert_button.setProperty("footerPrimary", True)
|
|
552
|
+
self.convert_button.setMinimumHeight(56)
|
|
553
|
+
self.convert_button.setSizePolicy(
|
|
554
|
+
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
|
555
|
+
)
|
|
556
|
+
self.convert_button.setDefault(True)
|
|
557
|
+
self.convert_button.setToolTip("Start conversion (Ctrl+Enter).")
|
|
558
|
+
self.convert_shortcuts = [
|
|
559
|
+
QShortcut(QKeySequence(key), self) for key in ("Ctrl+Return", "Ctrl+Enter")
|
|
560
|
+
]
|
|
561
|
+
for shortcut in self.convert_shortcuts:
|
|
562
|
+
shortcut.activated.connect(self.convert_button.click)
|
|
563
|
+
self.convert_button.setEnabled(False)
|
|
564
|
+
self.conversion_required_hint = QLabel("Choose a conversion to continue.")
|
|
565
|
+
self.conversion_required_hint.setObjectName("stepHint")
|
|
566
|
+
self.conversion_required_hint.setWordWrap(True)
|
|
567
|
+
self.convert_column = QWidget()
|
|
568
|
+
convert_layout = QVBoxLayout(self.convert_column)
|
|
569
|
+
convert_layout.setContentsMargins(0, 0, 0, 0)
|
|
570
|
+
convert_layout.setSpacing(6)
|
|
571
|
+
convert_layout.addWidget(self.conversion_required_hint)
|
|
572
|
+
convert_layout.addWidget(self.convert_button, 1)
|
|
573
|
+
self.conversion_dialog = ConversionDialog(self)
|
|
574
|
+
self.cancel_button = self.conversion_dialog.cancel_button
|
|
575
|
+
self.pause_button = self.conversion_dialog.pause_button
|
|
576
|
+
self.progress = self.conversion_dialog.progress
|
|
577
|
+
self.log = self.conversion_dialog.log
|
|
578
|
+
self.save_logs_button = self.conversion_dialog.save_logs_button
|
|
579
|
+
self.last_run_button = KeyboardPushButton("View last run")
|
|
580
|
+
self.last_run_button.setToolTip("Show the progress and logs of the last conversion.")
|
|
581
|
+
self.last_run_button.setEnabled(False)
|
|
582
|
+
self.last_run_button.hide()
|
|
583
|
+
self.open_output_button = KeyboardPushButton("Open output folder")
|
|
584
|
+
self.open_output_button.setEnabled(False)
|
|
585
|
+
self.open_output_button.hide()
|
|
586
|
+
self.summary = ElidedLabel()
|
|
587
|
+
self.summary.setObjectName("statusLabel")
|
|
588
|
+
self.summary.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
|
589
|
+
self.status = ElidedLabel()
|
|
590
|
+
self.status.setObjectName("statusLabel")
|
|
591
|
+
self.status.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
|
592
|
+
self.status.hide()
|
|
593
|
+
self.result_actions = QWidget()
|
|
594
|
+
self.result_actions_layout = QBoxLayout(
|
|
595
|
+
QBoxLayout.Direction.LeftToRight, self.result_actions
|
|
596
|
+
)
|
|
597
|
+
self.result_actions_layout.setContentsMargins(0, 0, 0, 0)
|
|
598
|
+
self.result_actions_layout.setSpacing(6)
|
|
599
|
+
self.result_actions_layout.addWidget(self.last_run_button)
|
|
600
|
+
self.result_actions_layout.addWidget(self.open_output_button)
|
|
601
|
+
self.result_actions.hide()
|
|
602
|
+
self.actions_row = QHBoxLayout()
|
|
603
|
+
self.actions_row.addWidget(self.status, 1)
|
|
604
|
+
self.actions_row.addWidget(self.result_actions)
|
|
605
|
+
self.result_row = QHBoxLayout()
|
|
606
|
+
self.result_row.addStretch()
|
|
607
|
+
footer_left = QWidget()
|
|
608
|
+
footer_left.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
|
609
|
+
footer_left_layout = QVBoxLayout(footer_left)
|
|
610
|
+
footer_left_layout.setContentsMargins(0, 0, 0, 0)
|
|
611
|
+
footer_left_layout.setSpacing(6)
|
|
612
|
+
footer_left_layout.addWidget(self.summary)
|
|
613
|
+
footer_left_layout.addLayout(self.actions_row)
|
|
614
|
+
footer_left_layout.addLayout(self.result_row)
|
|
615
|
+
self.footer = QWidget()
|
|
616
|
+
self.footer.setObjectName("actionBar")
|
|
617
|
+
self.footer.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
|
618
|
+
self.footer_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, self.footer)
|
|
619
|
+
self.footer_layout.setContentsMargins(26, 10, 26, 14)
|
|
620
|
+
self.footer_layout.setSpacing(self.body_layout.spacing())
|
|
621
|
+
self.footer_layout.addWidget(footer_left, 1)
|
|
622
|
+
self.footer_layout.addWidget(self.convert_column)
|
|
623
|
+
self.content_scroll = QScrollArea()
|
|
624
|
+
self.content_scroll.setWidgetResizable(True)
|
|
625
|
+
self.content_scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
626
|
+
self.content_scroll.setWidget(page)
|
|
627
|
+
self.content_scroll.viewport().installEventFilter(self)
|
|
628
|
+
central = QWidget()
|
|
629
|
+
central.setObjectName("page")
|
|
630
|
+
central_layout = QVBoxLayout(central)
|
|
631
|
+
central_layout.setContentsMargins(0, 0, 0, 0)
|
|
632
|
+
central_layout.setSpacing(0)
|
|
633
|
+
central_layout.addWidget(self.content_scroll, 1)
|
|
634
|
+
central_layout.addWidget(self.footer)
|
|
635
|
+
self.setCentralWidget(central)
|
|
636
|
+
if self._settings.contains("dark_theme"):
|
|
637
|
+
self._dark_theme = bool(self._settings.value("dark_theme", False, type=bool))
|
|
638
|
+
else:
|
|
639
|
+
self._dark_theme = QGuiApplication.styleHints().colorScheme() == Qt.ColorScheme.Dark
|
|
640
|
+
self.setStyleSheet(stylesheet_for(self._dark_theme))
|
|
641
|
+
self.theme_toggle.set_dark_theme(self._dark_theme)
|
|
642
|
+
self.files.set_dark_theme(self._dark_theme)
|
|
643
|
+
self.instance_tree.set_dark_theme(self._dark_theme)
|
|
644
|
+
self.drop_zone.set_dark_theme(self._dark_theme)
|
|
645
|
+
self.theme_toggle.themeChanged.connect(self._set_theme)
|
|
646
|
+
|
|
647
|
+
self.add_button.clicked.connect(self._choose_files)
|
|
648
|
+
self.add_folder_button.clicked.connect(self._choose_folder)
|
|
649
|
+
self.clear_button.clicked.connect(self._clear_files)
|
|
650
|
+
self.files.paths_dropped.connect(self._add_paths)
|
|
651
|
+
self.instance_tree.paths_dropped.connect(self._add_paths)
|
|
652
|
+
self.drop_zone.paths_dropped.connect(self._add_paths)
|
|
653
|
+
self.drop_zone.addFilesRequested.connect(self._choose_files)
|
|
654
|
+
self.drop_zone.addFolderRequested.connect(self._choose_folder)
|
|
655
|
+
self.files.selectionModel().selectionChanged.connect(lambda *_: self._update_buttons())
|
|
656
|
+
self.destination_button.clicked.connect(self._choose_destination)
|
|
657
|
+
self.destination_source_radio.toggled.connect(self._on_destination_mode_changed)
|
|
658
|
+
self.convert_button.clicked.connect(self._start_conversion)
|
|
659
|
+
self.cancel_button.clicked.connect(self._cancel_conversion)
|
|
660
|
+
self.pause_button.clicked.connect(self._toggle_pause)
|
|
661
|
+
self.open_output_button.clicked.connect(self._open_output_folder)
|
|
662
|
+
self.conversion_dialog.open_output_button.clicked.connect(self._open_output_folder)
|
|
663
|
+
self.save_logs_button.clicked.connect(self._save_logs)
|
|
664
|
+
self.last_run_button.clicked.connect(self._show_conversion_dialog)
|
|
665
|
+
self._configure_tab_order()
|
|
666
|
+
self.profile_options.set_conversion(None)
|
|
667
|
+
self._update_body_layout()
|
|
668
|
+
self._update_buttons()
|
|
669
|
+
|
|
670
|
+
def resizeEvent(self, event: QResizeEvent) -> None:
|
|
671
|
+
super().resizeEvent(event)
|
|
672
|
+
if hasattr(self, "body_layout"):
|
|
673
|
+
self._update_body_layout()
|
|
674
|
+
if hasattr(self, "result_actions"):
|
|
675
|
+
self._update_footer_layout()
|
|
676
|
+
|
|
677
|
+
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
|
678
|
+
if (
|
|
679
|
+
hasattr(self, "content_scroll")
|
|
680
|
+
and watched is self.content_scroll.viewport()
|
|
681
|
+
and event.type() == QEvent.Type.Resize
|
|
682
|
+
):
|
|
683
|
+
QTimer.singleShot(0, self._sync_footer_margins)
|
|
684
|
+
return super().eventFilter(watched, event)
|
|
685
|
+
|
|
686
|
+
def _sync_footer_margins(self) -> None:
|
|
687
|
+
scrollbar_width = self.content_scroll.width() - self.content_scroll.viewport().width()
|
|
688
|
+
if self.footer_layout.contentsMargins().right() != 26 + scrollbar_width:
|
|
689
|
+
self.footer_layout.setContentsMargins(26, 10, 26 + scrollbar_width, 14)
|
|
690
|
+
self._update_body_layout()
|
|
691
|
+
|
|
692
|
+
def _update_footer_layout(self) -> None:
|
|
693
|
+
width = self.width() - self.footer_layout.contentsMargins().left()
|
|
694
|
+
width -= self.footer_layout.contentsMargins().right()
|
|
695
|
+
if self.body_layout.direction() == QBoxLayout.Direction.LeftToRight:
|
|
696
|
+
width -= self.sidebar_scroll.width() + self.footer_layout.spacing()
|
|
697
|
+
self._wrap_button_text(self.last_run_button, "View last run", width, 28)
|
|
698
|
+
self._wrap_button_text(self.open_output_button, "Open output folder", width, 28)
|
|
699
|
+
buttons_width = (
|
|
700
|
+
self.last_run_button.sizeHint().width()
|
|
701
|
+
+ self.open_output_button.sizeHint().width()
|
|
702
|
+
+ self.result_actions_layout.spacing()
|
|
703
|
+
)
|
|
704
|
+
separate = self.width() < 650 or width < buttons_width + 80
|
|
705
|
+
self.result_actions_layout.setDirection(
|
|
706
|
+
QBoxLayout.Direction.TopToBottom
|
|
707
|
+
if width < buttons_width
|
|
708
|
+
else QBoxLayout.Direction.LeftToRight
|
|
709
|
+
)
|
|
710
|
+
if separate:
|
|
711
|
+
if self.result_row.indexOf(self.result_actions) < 0:
|
|
712
|
+
self.actions_row.removeWidget(self.result_actions)
|
|
713
|
+
self.result_row.addWidget(self.result_actions)
|
|
714
|
+
elif self.actions_row.indexOf(self.result_actions) < 0:
|
|
715
|
+
self.result_row.removeWidget(self.result_actions)
|
|
716
|
+
self.actions_row.insertWidget(1, self.result_actions)
|
|
717
|
+
|
|
718
|
+
def _update_result_actions(self) -> None:
|
|
719
|
+
self.result_actions.setVisible(
|
|
720
|
+
not self.last_run_button.isHidden() or not self.open_output_button.isHidden()
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
def showEvent(self, event: QShowEvent) -> None:
|
|
724
|
+
super().showEvent(event)
|
|
725
|
+
QTimer.singleShot(0, self._refresh_sidebar_metrics)
|
|
726
|
+
|
|
727
|
+
def _refresh_sidebar_metrics(self) -> None:
|
|
728
|
+
button_width = max(
|
|
729
|
+
self.organization_button.sizeHint().width(),
|
|
730
|
+
self.advanced_settings_button.sizeHint().width(),
|
|
731
|
+
)
|
|
732
|
+
self.organization_button.setMinimumWidth(button_width)
|
|
733
|
+
self.advanced_settings_button.setMinimumWidth(button_width)
|
|
734
|
+
destination_layout = self.destination_group.layout()
|
|
735
|
+
if destination_layout is not None:
|
|
736
|
+
destination_layout.invalidate()
|
|
737
|
+
destination_layout.activate()
|
|
738
|
+
panel = ProfileOptionsPanel()
|
|
739
|
+
panel.setAttribute(Qt.WidgetAttribute.WA_DontShowOnScreen)
|
|
740
|
+
panel.setStyleSheet(self.styleSheet())
|
|
741
|
+
panel.show()
|
|
742
|
+
widest = 0
|
|
743
|
+
for conversion in CONVERSIONS:
|
|
744
|
+
panel.set_conversion(conversion)
|
|
745
|
+
layout = panel.layout()
|
|
746
|
+
if layout is not None:
|
|
747
|
+
layout.invalidate()
|
|
748
|
+
layout.activate()
|
|
749
|
+
widest = max(widest, panel.minimumSizeHint().width())
|
|
750
|
+
panel.close()
|
|
751
|
+
panel.deleteLater()
|
|
752
|
+
self._profile_options_width = widest
|
|
753
|
+
self._update_body_layout()
|
|
754
|
+
|
|
755
|
+
def _update_body_layout(self) -> None:
|
|
756
|
+
direction = (
|
|
757
|
+
QBoxLayout.Direction.LeftToRight
|
|
758
|
+
if self.width() >= 1050
|
|
759
|
+
else QBoxLayout.Direction.TopToBottom
|
|
760
|
+
)
|
|
761
|
+
if self.body_layout.direction() != direction:
|
|
762
|
+
self.body_layout.setDirection(direction)
|
|
763
|
+
horizontal = direction == QBoxLayout.Direction.LeftToRight
|
|
764
|
+
self.footer_layout.setDirection(
|
|
765
|
+
QBoxLayout.Direction.LeftToRight if horizontal else QBoxLayout.Direction.BottomToTop
|
|
766
|
+
)
|
|
767
|
+
self.body_layout.setStretch(0, 1)
|
|
768
|
+
self.body_layout.setStretch(1, 0)
|
|
769
|
+
self.sidebar_scroll.setSizePolicy(
|
|
770
|
+
QSizePolicy.Policy.Maximum if horizontal else QSizePolicy.Policy.Expanding,
|
|
771
|
+
QSizePolicy.Policy.Expanding,
|
|
772
|
+
)
|
|
773
|
+
sidebar = self.sidebar_scroll.widget()
|
|
774
|
+
if horizontal:
|
|
775
|
+
conversion_layout = self.conversion_group.layout()
|
|
776
|
+
if conversion_layout is not None:
|
|
777
|
+
conversion_layout.invalidate()
|
|
778
|
+
widths = [
|
|
779
|
+
self.conversion_group.sizeHint().width(),
|
|
780
|
+
self.destination_group.minimumSizeHint().width(),
|
|
781
|
+
self._profile_options_width,
|
|
782
|
+
]
|
|
783
|
+
if not self.profile_options.isHidden():
|
|
784
|
+
widths.append(self.profile_options.minimumSizeHint().width())
|
|
785
|
+
scrollbar = self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent)
|
|
786
|
+
sidebar_width = max(widths) + scrollbar
|
|
787
|
+
self.sidebar_scroll.setFixedWidth(sidebar_width)
|
|
788
|
+
self.convert_column.setFixedWidth(sidebar_width)
|
|
789
|
+
else:
|
|
790
|
+
self.sidebar_scroll.setMaximumWidth(self._sidebar_maximum_width)
|
|
791
|
+
self.sidebar_scroll.setMinimumWidth(
|
|
792
|
+
sidebar.minimumSizeHint().width()
|
|
793
|
+
+ self.style().pixelMetric(QStyle.PixelMetric.PM_ScrollBarExtent)
|
|
794
|
+
if sidebar is not None
|
|
795
|
+
else 0
|
|
796
|
+
)
|
|
797
|
+
self.convert_column.setMinimumWidth(0)
|
|
798
|
+
self.convert_column.setMaximumWidth(16777215)
|
|
799
|
+
available = self.width() - 52
|
|
800
|
+
if horizontal:
|
|
801
|
+
available -= sidebar_width + self.body_layout.spacing()
|
|
802
|
+
if self.content_scroll.verticalScrollBar().isVisible():
|
|
803
|
+
available -= self.content_scroll.verticalScrollBar().width()
|
|
804
|
+
self._update_input_layout(available - 34)
|
|
805
|
+
self._update_footer_layout()
|
|
806
|
+
self.sidebar_scroll.setMinimumHeight(
|
|
807
|
+
200 if horizontal or sidebar is None else max(200, sidebar.sizeHint().height())
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
def _update_input_layout(self, width: int) -> None:
|
|
811
|
+
horizontal = QBoxLayout.Direction.LeftToRight
|
|
812
|
+
vertical = QBoxLayout.Direction.TopToBottom
|
|
813
|
+
self._wrap_button_text(self.recursive, "Recurse subfolders", width, 40)
|
|
814
|
+
self._wrap_button_text(self.hide_incompatible, "Hide incompatible", width, 40)
|
|
815
|
+
self._wrap_button_text(self.add_instance_button, "Add custom instance…", width, 28)
|
|
816
|
+
header_width = (
|
|
817
|
+
sum(
|
|
818
|
+
widget.sizeHint().width()
|
|
819
|
+
for widget in (
|
|
820
|
+
self.add_button,
|
|
821
|
+
self.add_folder_button,
|
|
822
|
+
self.recursive,
|
|
823
|
+
self.clear_button,
|
|
824
|
+
)
|
|
825
|
+
)
|
|
826
|
+
+ 3 * self.files_header.spacing()
|
|
827
|
+
)
|
|
828
|
+
self.files_header.setDirection(horizontal if width >= header_width else vertical)
|
|
829
|
+
self.input_buttons.setDirection(
|
|
830
|
+
horizontal
|
|
831
|
+
if width
|
|
832
|
+
>= self.add_button.sizeHint().width()
|
|
833
|
+
+ self.add_folder_button.sizeHint().width()
|
|
834
|
+
+ self.input_buttons.spacing()
|
|
835
|
+
else vertical
|
|
836
|
+
)
|
|
837
|
+
self.input_options.setDirection(
|
|
838
|
+
horizontal
|
|
839
|
+
if width
|
|
840
|
+
>= self.recursive.sizeHint().width()
|
|
841
|
+
+ self.clear_button.sizeHint().width()
|
|
842
|
+
+ self.input_options.spacing()
|
|
843
|
+
else vertical
|
|
844
|
+
)
|
|
845
|
+
self._wrap_button_text(
|
|
846
|
+
self.select_all_checkbox, self.select_all_checkbox.text().replace("\n", " "), width, 40
|
|
847
|
+
)
|
|
848
|
+
options_width = (
|
|
849
|
+
self.select_all_checkbox.sizeHint().width()
|
|
850
|
+
+ self.hide_incompatible.sizeHint().width()
|
|
851
|
+
+ self.file_options_row.spacing()
|
|
852
|
+
)
|
|
853
|
+
self.file_options_row.setDirection(horizontal if width >= options_width else vertical)
|
|
854
|
+
filter_width = 70 + sum(
|
|
855
|
+
widget.sizeHint().width() + self.filter_row.spacing()
|
|
856
|
+
for widget in (self.add_instance_button, self.instance_view_button)
|
|
857
|
+
if not widget.isHidden()
|
|
858
|
+
)
|
|
859
|
+
self.filter_row.setDirection(horizontal if width >= filter_width else vertical)
|
|
860
|
+
|
|
861
|
+
@staticmethod
|
|
862
|
+
def _wrap_button_text(button: QAbstractButton, text: str, width: int, padding: int) -> None:
|
|
863
|
+
lines: list[str] = []
|
|
864
|
+
for word in text.split():
|
|
865
|
+
candidate = f"{lines[-1]} {word}" if lines else word
|
|
866
|
+
if lines and button.fontMetrics().horizontalAdvance(candidate) + padding > width:
|
|
867
|
+
lines.append(word)
|
|
868
|
+
elif lines:
|
|
869
|
+
lines[-1] = candidate
|
|
870
|
+
else:
|
|
871
|
+
lines.append(word)
|
|
872
|
+
wrapped = "\n".join(lines)
|
|
873
|
+
if button.text() != wrapped:
|
|
874
|
+
button.setText(wrapped)
|
|
875
|
+
|
|
876
|
+
def _configure_tab_order(self) -> None:
|
|
877
|
+
profile = self.profile_options
|
|
878
|
+
chain: list[QWidget] = [
|
|
879
|
+
self.theme_toggle,
|
|
880
|
+
self.add_button,
|
|
881
|
+
self.add_folder_button,
|
|
882
|
+
self.recursive,
|
|
883
|
+
self.clear_button,
|
|
884
|
+
self.select_all_checkbox,
|
|
885
|
+
self.hide_incompatible,
|
|
886
|
+
self.font_filter,
|
|
887
|
+
self.add_instance_button,
|
|
888
|
+
self.instance_view_button,
|
|
889
|
+
self.drop_zone.add_files_button,
|
|
890
|
+
self.drop_zone.add_folder_button,
|
|
891
|
+
self.files,
|
|
892
|
+
self.instance_tree,
|
|
893
|
+
*self.conversion.findChildren(QRadioButton),
|
|
894
|
+
profile.tolerance_field.decrement_button,
|
|
895
|
+
profile.tolerance,
|
|
896
|
+
profile.tolerance_field.increment_button,
|
|
897
|
+
profile.optimize_contours_field.decrement_button,
|
|
898
|
+
profile.optimize_contours,
|
|
899
|
+
profile.optimize_contours_field.increment_button,
|
|
900
|
+
profile.use_target_upm,
|
|
901
|
+
profile.target_upm_field.decrement_button,
|
|
902
|
+
profile.target_upm,
|
|
903
|
+
profile.target_upm_field.increment_button,
|
|
904
|
+
profile.subroutinize,
|
|
905
|
+
profile.autohint,
|
|
906
|
+
profile.format_woff,
|
|
907
|
+
profile.format_woff2,
|
|
908
|
+
profile.obfuscate_names,
|
|
909
|
+
profile.remove_unused_glyphs,
|
|
910
|
+
profile.remove_non_windows_names,
|
|
911
|
+
profile.remove_dsig,
|
|
912
|
+
profile.css_kit,
|
|
913
|
+
self.destination_source_radio,
|
|
914
|
+
self.destination_folder_radio,
|
|
915
|
+
self.destination_button,
|
|
916
|
+
self.overwrite,
|
|
917
|
+
self.organization_button,
|
|
918
|
+
self.advanced_settings_button,
|
|
919
|
+
self.last_run_button,
|
|
920
|
+
self.open_output_button,
|
|
921
|
+
self.convert_button,
|
|
922
|
+
]
|
|
923
|
+
for current, following in zip(chain, chain[1:], strict=False):
|
|
924
|
+
QWidget.setTabOrder(current, following)
|
|
925
|
+
|
|
926
|
+
def _show_column_menu(self, pos: QPoint) -> None:
|
|
927
|
+
self.column_menu.popup(self.files.horizontalHeader().mapToGlobal(pos))
|
|
928
|
+
|
|
929
|
+
def _set_column_visible(self, visible: bool) -> None:
|
|
930
|
+
action = self.sender()
|
|
931
|
+
if not isinstance(action, QAction):
|
|
932
|
+
raise TypeError("Column visibility changes must come from a QAction")
|
|
933
|
+
column = int(action.data())
|
|
934
|
+
self.files.setColumnHidden(column, not visible)
|
|
935
|
+
self._settings.setValue(OPTIONAL_FONT_COLUMNS[column][1], visible)
|
|
936
|
+
|
|
937
|
+
def _set_theme(self, enabled: bool) -> None:
|
|
938
|
+
self._dark_theme = enabled
|
|
939
|
+
self._settings.setValue("dark_theme", enabled)
|
|
940
|
+
self.setStyleSheet(stylesheet_for(enabled))
|
|
941
|
+
self.files.set_dark_theme(enabled)
|
|
942
|
+
self.instance_tree.set_dark_theme(enabled)
|
|
943
|
+
self.drop_zone.set_dark_theme(enabled)
|
|
944
|
+
self.theme_toggle.set_dark_theme(enabled)
|
|
945
|
+
self._refresh_sidebar_metrics()
|
|
946
|
+
|
|
947
|
+
def _on_conversion_changed(self) -> None:
|
|
948
|
+
conversion = self.conversion.currentData()
|
|
949
|
+
if conversion is None:
|
|
950
|
+
return
|
|
951
|
+
self.profile_options.set_conversion(conversion)
|
|
952
|
+
available = set(available_organization_criteria(conversion))
|
|
953
|
+
self.organization_levels = tuple(
|
|
954
|
+
filtered
|
|
955
|
+
for level in self.organization_levels
|
|
956
|
+
if (filtered := tuple(key for key in level if key in available))
|
|
957
|
+
)
|
|
958
|
+
self._update_organization_button()
|
|
959
|
+
self._update_body_layout()
|
|
960
|
+
|
|
961
|
+
def _organization_preview(self) -> tuple[dict[str, str], str]:
|
|
962
|
+
conversion = str(self.conversion.currentData())
|
|
963
|
+
format_: str = self.profile_options.selected_web_format() or "woff2"
|
|
964
|
+
if conversion != "ft2wf":
|
|
965
|
+
format_ = "opentype"
|
|
966
|
+
outline = {
|
|
967
|
+
"ttf2otf": "ps",
|
|
968
|
+
"otf2ttf": "tt",
|
|
969
|
+
"var2static": "tt",
|
|
970
|
+
}.get(conversion, "tt")
|
|
971
|
+
upm = (
|
|
972
|
+
str(self.profile_options.target_upm.value())
|
|
973
|
+
if self.profile_options.use_target_upm.isChecked()
|
|
974
|
+
else "1000"
|
|
975
|
+
)
|
|
976
|
+
values = {
|
|
977
|
+
"family": "Sample Family",
|
|
978
|
+
"format": format_,
|
|
979
|
+
"outline": outline,
|
|
980
|
+
"upm": upm,
|
|
981
|
+
"tolerance": f"{self.profile_options.tolerance.value():g}",
|
|
982
|
+
"simplify": str(self.profile_options.optimize_contours.value()),
|
|
983
|
+
}
|
|
984
|
+
extension = {
|
|
985
|
+
"ttf2otf": ".otf",
|
|
986
|
+
"otf2ttf": ".ttf",
|
|
987
|
+
"ft2wf": f".{format_}",
|
|
988
|
+
"woff2ft": ".ttf",
|
|
989
|
+
"woff22ft": ".ttf",
|
|
990
|
+
"ttc2sfnt": ".ttf",
|
|
991
|
+
"var2static": ".ttf",
|
|
992
|
+
}.get(conversion, ".ttf")
|
|
993
|
+
return values, extension
|
|
994
|
+
|
|
995
|
+
def _show_organization_dialog(self) -> None:
|
|
996
|
+
values, extension = self._organization_preview()
|
|
997
|
+
dialog = OrganizationDialog(
|
|
998
|
+
str(self.conversion.currentData()),
|
|
999
|
+
self.organization_levels,
|
|
1000
|
+
values,
|
|
1001
|
+
extension,
|
|
1002
|
+
self,
|
|
1003
|
+
)
|
|
1004
|
+
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
1005
|
+
self.organization_levels = dialog.levels
|
|
1006
|
+
self._update_organization_button()
|
|
1007
|
+
self._update_body_layout()
|
|
1008
|
+
|
|
1009
|
+
def _update_organization_button(self, *_: object) -> None:
|
|
1010
|
+
self.organization_preview.setVisible(bool(self.organization_levels))
|
|
1011
|
+
if not self.organization_levels:
|
|
1012
|
+
self.organization_button.setToolTip(
|
|
1013
|
+
"Choose how converted fonts are grouped into folders."
|
|
1014
|
+
)
|
|
1015
|
+
self.organization_preview.clear()
|
|
1016
|
+
return
|
|
1017
|
+
summary = " → ".join(
|
|
1018
|
+
" + ".join(CRITERIA[key] for key in level) for level in self.organization_levels
|
|
1019
|
+
)
|
|
1020
|
+
self.organization_button.setToolTip(f"Output folder levels: {summary}")
|
|
1021
|
+
values, extension = self._organization_preview()
|
|
1022
|
+
pieces = [organization_level_name(level, values) for level in self.organization_levels]
|
|
1023
|
+
self.organization_preview.setText(f"Example: {'/'.join(pieces)}/font{extension}")
|
|
1024
|
+
|
|
1025
|
+
def _show_advanced_settings(self) -> None:
|
|
1026
|
+
previous = (
|
|
1027
|
+
self.recalc_bboxes.isChecked(),
|
|
1028
|
+
self.recalc_timestamp.isChecked(),
|
|
1029
|
+
self.reorder_tables.isChecked(),
|
|
1030
|
+
)
|
|
1031
|
+
if self.advanced_settings_dialog.exec() == QDialog.DialogCode.Rejected:
|
|
1032
|
+
for checkbox, checked in zip(
|
|
1033
|
+
(self.recalc_bboxes, self.recalc_timestamp, self.reorder_tables),
|
|
1034
|
+
previous,
|
|
1035
|
+
strict=True,
|
|
1036
|
+
):
|
|
1037
|
+
checkbox.setChecked(checked)
|
|
1038
|
+
|
|
1039
|
+
def _saved_dir(self, key: str) -> str:
|
|
1040
|
+
value = self._settings.value(key, "")
|
|
1041
|
+
return value if isinstance(value, str) and Path(value).is_dir() else ""
|
|
1042
|
+
|
|
1043
|
+
def _choose_files(self) -> None:
|
|
1044
|
+
paths, _ = QFileDialog.getOpenFileNames(
|
|
1045
|
+
self, "Select font files", self._saved_dir(INPUT_DIR_KEY), FONT_FILTER
|
|
1046
|
+
)
|
|
1047
|
+
if paths:
|
|
1048
|
+
self._settings.setValue(INPUT_DIR_KEY, str(Path(paths[0]).parent))
|
|
1049
|
+
self._add_paths(paths)
|
|
1050
|
+
|
|
1051
|
+
def _choose_folder(self) -> None:
|
|
1052
|
+
selected = QFileDialog.getExistingDirectory(
|
|
1053
|
+
self, "Select font folder", self._saved_dir(INPUT_DIR_KEY) or str(Path.home())
|
|
1054
|
+
)
|
|
1055
|
+
if selected:
|
|
1056
|
+
self._settings.setValue(INPUT_DIR_KEY, selected)
|
|
1057
|
+
self._add_paths([selected])
|
|
1058
|
+
|
|
1059
|
+
def _add_paths(self, paths: list[str]) -> None:
|
|
1060
|
+
for raw_path in paths:
|
|
1061
|
+
path = Path(raw_path)
|
|
1062
|
+
if not path.is_dir() and not path.is_file():
|
|
1063
|
+
continue
|
|
1064
|
+
path = path.resolve()
|
|
1065
|
+
if path in self._sources:
|
|
1066
|
+
continue
|
|
1067
|
+
self._sources.append(path)
|
|
1068
|
+
self._refresh_folder_contents()
|
|
1069
|
+
|
|
1070
|
+
def _refresh_folder_contents(self) -> None:
|
|
1071
|
+
self._remember_input_checks()
|
|
1072
|
+
self._stop_scan()
|
|
1073
|
+
self.files.setSortingEnabled(False)
|
|
1074
|
+
self.file_list.clear()
|
|
1075
|
+
self.instance_tree.clear_fonts()
|
|
1076
|
+
self.conversion.set_compatibility_counts(None, 0)
|
|
1077
|
+
self._update_body_layout()
|
|
1078
|
+
self._scan_cancelled = False
|
|
1079
|
+
if not self._sources:
|
|
1080
|
+
self.files.setSortingEnabled(True)
|
|
1081
|
+
self._update_buttons()
|
|
1082
|
+
return
|
|
1083
|
+
self.scan_progress.setRange(0, 0)
|
|
1084
|
+
self.scan_progress.setVisible(True)
|
|
1085
|
+
self.scan_label.setText("Searching for files…")
|
|
1086
|
+
self.scan_widget.setVisible(True)
|
|
1087
|
+
self.scan_stop_button.setEnabled(True)
|
|
1088
|
+
self.file_list.start(list(self._sources), self.recursive.isChecked())
|
|
1089
|
+
self._update_buttons()
|
|
1090
|
+
|
|
1091
|
+
def _stop_scan(self) -> None:
|
|
1092
|
+
self.file_list.stop()
|
|
1093
|
+
self.scan_widget.setVisible(False)
|
|
1094
|
+
|
|
1095
|
+
def _cancel_scan(self) -> None:
|
|
1096
|
+
if not self.file_list.scanning:
|
|
1097
|
+
return
|
|
1098
|
+
self._scan_cancelled = True
|
|
1099
|
+
self.scan_stop_button.setEnabled(False)
|
|
1100
|
+
self.file_list.cancel()
|
|
1101
|
+
|
|
1102
|
+
def _on_scan_progress(self, done: int, total: int) -> None:
|
|
1103
|
+
if total == 0:
|
|
1104
|
+
self.scan_progress.setRange(0, 0)
|
|
1105
|
+
self.scan_label.setText("Searching for files…")
|
|
1106
|
+
return
|
|
1107
|
+
self.scan_progress.setRange(0, total)
|
|
1108
|
+
self.scan_progress.setValue(done)
|
|
1109
|
+
self.scan_label.setText(f"Scanning fonts… {done} / {total}")
|
|
1110
|
+
|
|
1111
|
+
def _is_instance_mode(self) -> bool:
|
|
1112
|
+
return self.conversion.currentData() == "var2static"
|
|
1113
|
+
|
|
1114
|
+
def _on_font_found(self, path: Path, info: FontInfo) -> None:
|
|
1115
|
+
source = next((s for s in self._sources if s.is_dir() and path.is_relative_to(s)), None)
|
|
1116
|
+
label = path.name if source is None else path.relative_to(source).as_posix()
|
|
1117
|
+
checked = self._input_check_states.setdefault(path, True)
|
|
1118
|
+
was_blocked = self.files.blockSignals(True)
|
|
1119
|
+
self.file_list.add_font(path, label, info, checked)
|
|
1120
|
+
self.files.blockSignals(was_blocked)
|
|
1121
|
+
if info.variable and not info.collection:
|
|
1122
|
+
self.instance_tree.add_font(path, label, info, checked_by_default=checked)
|
|
1123
|
+
else:
|
|
1124
|
+
reason = incompatibility(info, "var2static") or "Not a variable font"
|
|
1125
|
+
self.instance_tree.add_skipped(path, label, reason, info)
|
|
1126
|
+
if self.file_list.row_count == 1:
|
|
1127
|
+
self._update_buttons()
|
|
1128
|
+
|
|
1129
|
+
def _sync_instance_actions(self) -> None:
|
|
1130
|
+
self.add_instance_button.setEnabled(self.instance_tree.can_add())
|
|
1131
|
+
|
|
1132
|
+
def _on_scan_finished(self) -> None:
|
|
1133
|
+
self.scan_progress.setVisible(False)
|
|
1134
|
+
self.files.setSortingEnabled(True)
|
|
1135
|
+
header = self.files.horizontalHeader()
|
|
1136
|
+
self.files.sortByColumn(header.sortIndicatorSection(), header.sortIndicatorOrder())
|
|
1137
|
+
self.instance_tree.auto_expand()
|
|
1138
|
+
rows = list(self.file_list.rows())
|
|
1139
|
+
if rows:
|
|
1140
|
+
counts = {
|
|
1141
|
+
conversion: sum(incompatibility(row.info, conversion) is None for row in rows)
|
|
1142
|
+
for conversion in CONVERSIONS
|
|
1143
|
+
}
|
|
1144
|
+
self.conversion.set_compatibility_counts(counts, len(rows))
|
|
1145
|
+
self._update_body_layout()
|
|
1146
|
+
self._update_buttons()
|
|
1147
|
+
|
|
1148
|
+
def _apply_compatibility(self, *_: object) -> None:
|
|
1149
|
+
self.file_list.apply_compatibility()
|
|
1150
|
+
self.instance_tree.set_hide_incompatible(self.hide_incompatible.isChecked())
|
|
1151
|
+
|
|
1152
|
+
def _selected_input_paths(self) -> list[Path]:
|
|
1153
|
+
if self._is_instance_mode():
|
|
1154
|
+
return self.instance_tree.selected_paths()
|
|
1155
|
+
return self.file_list.selected_paths()
|
|
1156
|
+
|
|
1157
|
+
def _remember_input_checks(self) -> None:
|
|
1158
|
+
self._input_check_states.update(self.file_list.remember_checks())
|
|
1159
|
+
|
|
1160
|
+
def _on_file_item_changed(self, *_: object) -> None:
|
|
1161
|
+
for row in self.file_list.rows():
|
|
1162
|
+
if (
|
|
1163
|
+
row.path in self._input_check_states
|
|
1164
|
+
and self._input_check_states[row.path] != row.checked
|
|
1165
|
+
):
|
|
1166
|
+
self.instance_tree.set_font_checked(row.path, row.checked)
|
|
1167
|
+
self._remember_input_checks()
|
|
1168
|
+
self._update_buttons()
|
|
1169
|
+
|
|
1170
|
+
def _set_visible_checks(self, checked: bool) -> None:
|
|
1171
|
+
if self._is_instance_mode():
|
|
1172
|
+
self.instance_tree.set_all_checked(checked)
|
|
1173
|
+
return
|
|
1174
|
+
self.file_list.set_visible_checks(checked)
|
|
1175
|
+
self._remember_input_checks()
|
|
1176
|
+
self._update_buttons()
|
|
1177
|
+
|
|
1178
|
+
def _toggle_visible_checks(self) -> None:
|
|
1179
|
+
checked = self.select_all_checkbox.checkState() != Qt.CheckState.Unchecked
|
|
1180
|
+
self._set_visible_checks(checked)
|
|
1181
|
+
|
|
1182
|
+
def _clear_files(self) -> None:
|
|
1183
|
+
self._sources.clear()
|
|
1184
|
+
self._input_check_states.clear()
|
|
1185
|
+
self.instance_tree.forget()
|
|
1186
|
+
self._refresh_folder_contents()
|
|
1187
|
+
|
|
1188
|
+
def _choose_destination(self) -> None:
|
|
1189
|
+
start = str(self.output_directory or self._saved_dir(OUTPUT_DIR_KEY) or Path.home())
|
|
1190
|
+
selected = QFileDialog.getExistingDirectory(self, "Choose output folder", start)
|
|
1191
|
+
if selected:
|
|
1192
|
+
self._settings.setValue(OUTPUT_DIR_KEY, selected)
|
|
1193
|
+
self.output_directory = Path(selected)
|
|
1194
|
+
self.destination_edit.setText(selected)
|
|
1195
|
+
self._update_buttons()
|
|
1196
|
+
elif self.output_directory is None:
|
|
1197
|
+
self.destination_source_radio.setChecked(True)
|
|
1198
|
+
|
|
1199
|
+
def _on_destination_mode_changed(self, source_selected: bool) -> None:
|
|
1200
|
+
if source_selected:
|
|
1201
|
+
self.output_directory = None
|
|
1202
|
+
self.destination_edit.clear()
|
|
1203
|
+
self.destination_edit.setPlaceholderText("")
|
|
1204
|
+
else:
|
|
1205
|
+
self.destination_edit.setPlaceholderText("Choose an output folder…")
|
|
1206
|
+
saved_directory = self._saved_dir(OUTPUT_DIR_KEY)
|
|
1207
|
+
if saved_directory:
|
|
1208
|
+
self.destination_edit.setText(saved_directory)
|
|
1209
|
+
self.output_directory = Path(saved_directory)
|
|
1210
|
+
else:
|
|
1211
|
+
self.output_directory = None
|
|
1212
|
+
self._choose_destination()
|
|
1213
|
+
self._update_buttons()
|
|
1214
|
+
|
|
1215
|
+
def _web_format_valid(self) -> bool:
|
|
1216
|
+
conversion = self.conversion.currentData()
|
|
1217
|
+
if conversion != "ft2wf":
|
|
1218
|
+
return True
|
|
1219
|
+
return self.profile_options.web_format_valid()
|
|
1220
|
+
|
|
1221
|
+
def _update_buttons(self) -> None:
|
|
1222
|
+
instance_mode = self._is_instance_mode()
|
|
1223
|
+
has_rows = self.file_list.row_count > 0
|
|
1224
|
+
self.drop_zone.set_scanning(self.file_list.scanning)
|
|
1225
|
+
self.files_stack.setCurrentWidget(
|
|
1226
|
+
self.drop_zone if not has_rows else self.instance_tree if instance_mode else self.files
|
|
1227
|
+
)
|
|
1228
|
+
self.instance_view_hint.setVisible(instance_mode and has_rows)
|
|
1229
|
+
self.hide_incompatible.setVisible(True)
|
|
1230
|
+
self.font_filter.setVisible(True)
|
|
1231
|
+
if instance_mode:
|
|
1232
|
+
self.font_filter.setPlaceholderText("Filter fonts or instances…")
|
|
1233
|
+
self.font_filter.setToolTip(
|
|
1234
|
+
"Show fonts and instances whose names, locations, or paths match."
|
|
1235
|
+
)
|
|
1236
|
+
else:
|
|
1237
|
+
self.font_filter.setPlaceholderText(FONT_FILTER_PLACEHOLDER)
|
|
1238
|
+
self.font_filter.setToolTip("Filter the font list by matching text.")
|
|
1239
|
+
has_fonts = self.instance_tree.font_count() > 0
|
|
1240
|
+
instance_controls_visible = not self.add_instance_button.isHidden()
|
|
1241
|
+
self.add_instance_button.setVisible(instance_mode and has_fonts)
|
|
1242
|
+
self.instance_view_button.setVisible(instance_mode and has_fonts)
|
|
1243
|
+
if instance_controls_visible != (instance_mode and has_fonts):
|
|
1244
|
+
self._update_body_layout()
|
|
1245
|
+
self.font_filter.setEnabled(
|
|
1246
|
+
self.instance_tree.font_count() > 0 if instance_mode else self.file_list.row_count > 0
|
|
1247
|
+
)
|
|
1248
|
+
self.profile_options.set_instance_outlines(
|
|
1249
|
+
self.instance_tree.selected_outlines() if instance_mode else set()
|
|
1250
|
+
)
|
|
1251
|
+
self.select_all_checkbox.setText(
|
|
1252
|
+
"Select all instances"
|
|
1253
|
+
if instance_mode
|
|
1254
|
+
else "Select compatible fonts"
|
|
1255
|
+
if self.conversion.currentData() is not None
|
|
1256
|
+
else "Select all fonts"
|
|
1257
|
+
)
|
|
1258
|
+
self._update_body_layout()
|
|
1259
|
+
self._sync_instance_actions()
|
|
1260
|
+
has_roots = bool(self._sources)
|
|
1261
|
+
selected = (
|
|
1262
|
+
self.instance_tree.instance_count()
|
|
1263
|
+
if instance_mode
|
|
1264
|
+
else len(self.file_list.selected_paths())
|
|
1265
|
+
)
|
|
1266
|
+
has_files = selected > 0
|
|
1267
|
+
has_logs = self.log.count() > 0
|
|
1268
|
+
scanning = self.file_list.scanning
|
|
1269
|
+
folder_mode = self.destination_folder_radio.isChecked()
|
|
1270
|
+
conversion = self.conversion.currentData()
|
|
1271
|
+
self.conversion_required_hint.setVisible(conversion is None)
|
|
1272
|
+
unit = "instance" if instance_mode else "font"
|
|
1273
|
+
if selected != 1:
|
|
1274
|
+
unit += "s"
|
|
1275
|
+
self.convert_button.setText(f"Convert {selected} {unit}" if selected else "Convert")
|
|
1276
|
+
self.convert_button.setEnabled(
|
|
1277
|
+
conversion is not None
|
|
1278
|
+
and has_files
|
|
1279
|
+
and not scanning
|
|
1280
|
+
and self.worker is None
|
|
1281
|
+
and self._web_format_valid()
|
|
1282
|
+
)
|
|
1283
|
+
self.conversion_dialog.set_running(self.worker is not None)
|
|
1284
|
+
self.last_run_button.setEnabled(has_logs)
|
|
1285
|
+
self.last_run_button.setVisible(self._last_run_available and has_logs)
|
|
1286
|
+
self._update_result_actions()
|
|
1287
|
+
self.pause_button.setText("Resume" if self._run_state is RunState.PAUSED else "Pause")
|
|
1288
|
+
self.pause_button.setEnabled(
|
|
1289
|
+
self._run_state in (RunState.RUNNING, RunState.PAUSED) and not self._cancelling
|
|
1290
|
+
)
|
|
1291
|
+
self.clear_button.setEnabled(has_roots)
|
|
1292
|
+
check_state = (
|
|
1293
|
+
self.instance_tree.check_state()
|
|
1294
|
+
if instance_mode
|
|
1295
|
+
else self.file_list.visible_check_state()
|
|
1296
|
+
)
|
|
1297
|
+
self.select_all_checkbox.setEnabled(check_state is not None and not scanning)
|
|
1298
|
+
self.select_all_checkbox.setCheckState(
|
|
1299
|
+
Qt.CheckState.Unchecked if check_state is None else check_state
|
|
1300
|
+
)
|
|
1301
|
+
self.destination_edit.setEnabled(folder_mode)
|
|
1302
|
+
self.destination_button.setEnabled(folder_mode)
|
|
1303
|
+
if scanning:
|
|
1304
|
+
summary = "Wait for the font scan to finish."
|
|
1305
|
+
elif self.file_list.row_count == 0:
|
|
1306
|
+
summary = "Add font files or folders to get started."
|
|
1307
|
+
elif conversion is None:
|
|
1308
|
+
summary = f"{selected} {unit} selected" if has_files else "No fonts selected."
|
|
1309
|
+
elif not has_files:
|
|
1310
|
+
summary = "Select compatible fonts or instances to continue."
|
|
1311
|
+
elif not self._web_format_valid():
|
|
1312
|
+
summary = "Choose at least one web font format to continue."
|
|
1313
|
+
else:
|
|
1314
|
+
destination = str(self.output_directory) if folder_mode else "same as source"
|
|
1315
|
+
summary = f"{CONVERSIONS[conversion]} · {selected} {unit} selected · {destination}"
|
|
1316
|
+
self.summary.setText(summary)
|
|
1317
|
+
self.save_logs_button.setEnabled(has_logs and self.worker is None)
|
|
1318
|
+
self.scan_stop_button.setVisible(scanning)
|
|
1319
|
+
variable_count = sum(
|
|
1320
|
+
1
|
|
1321
|
+
for row in self.file_list.rows()
|
|
1322
|
+
if row.info.variable and not row.info.collection and row.reason is not None
|
|
1323
|
+
)
|
|
1324
|
+
self.variable_hint.setVisible(
|
|
1325
|
+
not scanning and conversion is not None and variable_count > 0
|
|
1326
|
+
)
|
|
1327
|
+
if variable_count:
|
|
1328
|
+
unit = "font" if variable_count == 1 else "fonts"
|
|
1329
|
+
self.variable_hint.setText(
|
|
1330
|
+
f"{variable_count} variable {unit} found: "
|
|
1331
|
+
"use Variable → Static to export instances."
|
|
1332
|
+
)
|
|
1333
|
+
if not scanning:
|
|
1334
|
+
count = self.file_list.row_count
|
|
1335
|
+
compatible = self.file_list.compatible_count()
|
|
1336
|
+
if instance_mode:
|
|
1337
|
+
fonts = self.instance_tree.font_count()
|
|
1338
|
+
instances = self.instance_tree.instance_count()
|
|
1339
|
+
text = (
|
|
1340
|
+
f"{fonts} variable font{'' if fonts == 1 else 's'} · "
|
|
1341
|
+
f"{instances} instance{'' if instances == 1 else 's'} selected"
|
|
1342
|
+
)
|
|
1343
|
+
skipped = self.instance_tree.skipped_count()
|
|
1344
|
+
if skipped:
|
|
1345
|
+
text += f" · {skipped} skipped"
|
|
1346
|
+
if count == 0:
|
|
1347
|
+
text = "No fonts found"
|
|
1348
|
+
elif count == 0:
|
|
1349
|
+
text = "No fonts found"
|
|
1350
|
+
else:
|
|
1351
|
+
text = f"{count} font{'' if count == 1 else 's'} found"
|
|
1352
|
+
if conversion is None:
|
|
1353
|
+
text += " · choose a conversion"
|
|
1354
|
+
elif compatible == 0:
|
|
1355
|
+
text += " · none compatible with this profile"
|
|
1356
|
+
elif compatible != count:
|
|
1357
|
+
text += f" · {compatible} compatible"
|
|
1358
|
+
if self._scan_cancelled:
|
|
1359
|
+
text = "Scan stopped · " + text
|
|
1360
|
+
if not instance_mode:
|
|
1361
|
+
selected_rows = self.files.selectionModel().selectedRows()
|
|
1362
|
+
if len(selected_rows) == 1:
|
|
1363
|
+
reason = selected_rows[0].data(Qt.ItemDataRole.UserRole + 1)
|
|
1364
|
+
if reason:
|
|
1365
|
+
text += f" · {reason}"
|
|
1366
|
+
self.scan_label.setText(text)
|
|
1367
|
+
self.scan_label.setToolTip(text)
|
|
1368
|
+
self.scan_widget.setVisible(has_roots)
|
|
1369
|
+
|
|
1370
|
+
def _append_log(self, entry: LogEntry) -> None:
|
|
1371
|
+
self._log_entries.append(entry)
|
|
1372
|
+
self.log.addItem(entry.display_text())
|
|
1373
|
+
self.log.scrollToBottom()
|
|
1374
|
+
self._update_buttons()
|
|
1375
|
+
|
|
1376
|
+
def _show_conversion_dialog(self) -> None:
|
|
1377
|
+
self.conversion_dialog.open()
|
|
1378
|
+
|
|
1379
|
+
def _set_output_available(self, available: bool) -> None:
|
|
1380
|
+
self.open_output_button.setEnabled(available)
|
|
1381
|
+
self.open_output_button.setVisible(available)
|
|
1382
|
+
self._update_result_actions()
|
|
1383
|
+
self.conversion_dialog.open_output_button.setEnabled(available)
|
|
1384
|
+
|
|
1385
|
+
def _set_status(self, text: str, state: str = "") -> None:
|
|
1386
|
+
self.conversion_dialog.set_status(text, state)
|
|
1387
|
+
self.status.setText(text)
|
|
1388
|
+
self.status.show()
|
|
1389
|
+
self.status.setProperty("state", state)
|
|
1390
|
+
self.status.style().unpolish(self.status)
|
|
1391
|
+
self.status.style().polish(self.status)
|
|
1392
|
+
|
|
1393
|
+
def _save_logs(self) -> None:
|
|
1394
|
+
if not self._log_entries:
|
|
1395
|
+
self._set_status("No logs available to save.", "warning")
|
|
1396
|
+
return
|
|
1397
|
+
start = str(Path(self._saved_dir(LOG_DIR_KEY) or Path.home()) / "font-converter.jsonl")
|
|
1398
|
+
selected, _ = QFileDialog.getSaveFileName(
|
|
1399
|
+
self, "Save logs", start, "JSON Lines logs (*.jsonl);;All files (*)"
|
|
1400
|
+
)
|
|
1401
|
+
if not selected:
|
|
1402
|
+
return
|
|
1403
|
+
text = "\n".join(entry.to_json() for entry in self._log_entries)
|
|
1404
|
+
try:
|
|
1405
|
+
Path(selected).write_text(text + "\n", encoding="utf-8")
|
|
1406
|
+
except OSError as error:
|
|
1407
|
+
self._set_status(f"Could not save logs: {error}", "error")
|
|
1408
|
+
return
|
|
1409
|
+
self._settings.setValue(LOG_DIR_KEY, str(Path(selected).parent))
|
|
1410
|
+
self._set_status(f"Logs saved to {selected}.")
|
|
1411
|
+
|
|
1412
|
+
def _options(self) -> ConversionSettings:
|
|
1413
|
+
conversion = self.conversion.currentData()
|
|
1414
|
+
if conversion is None:
|
|
1415
|
+
raise ValueError("Choose a conversion profile first.")
|
|
1416
|
+
common = ConversionOptions(
|
|
1417
|
+
recursive=self.recursive.isChecked(),
|
|
1418
|
+
output_dir=self.output_directory,
|
|
1419
|
+
overwrite=self.overwrite.isChecked(),
|
|
1420
|
+
recalc_bboxes=self.recalc_bboxes.isChecked(),
|
|
1421
|
+
recalc_timestamp=self.recalc_timestamp.isChecked(),
|
|
1422
|
+
reorder_tables=self.reorder_tables.isChecked(),
|
|
1423
|
+
organization_levels=self.organization_levels,
|
|
1424
|
+
)
|
|
1425
|
+
return self.profile_options.settings_for(
|
|
1426
|
+
conversion, common, self.instance_tree.selection() if conversion == "var2static" else ()
|
|
1427
|
+
)
|
|
1428
|
+
|
|
1429
|
+
def _compatibility_profile(self) -> tuple[str | None, bool]:
|
|
1430
|
+
return (
|
|
1431
|
+
self.conversion.currentData(),
|
|
1432
|
+
self.hide_incompatible.isChecked(),
|
|
1433
|
+
)
|
|
1434
|
+
|
|
1435
|
+
def _start_conversion(self) -> None:
|
|
1436
|
+
if self.conversion.currentData() is None:
|
|
1437
|
+
return
|
|
1438
|
+
input_paths = self._selected_input_paths()
|
|
1439
|
+
if not input_paths:
|
|
1440
|
+
return
|
|
1441
|
+
if any(not path.exists() for path in input_paths):
|
|
1442
|
+
QMessageBox.critical(
|
|
1443
|
+
self, "Input unavailable", "One or more selected inputs no longer exist."
|
|
1444
|
+
)
|
|
1445
|
+
return
|
|
1446
|
+
|
|
1447
|
+
total = (
|
|
1448
|
+
self.instance_tree.instance_count() if self._is_instance_mode() else len(input_paths)
|
|
1449
|
+
)
|
|
1450
|
+
self._log_entries.clear()
|
|
1451
|
+
self.log.clear()
|
|
1452
|
+
self._last_run_available = False
|
|
1453
|
+
self._run_output_directory = self.output_directory
|
|
1454
|
+
self._run_input_directory = input_paths[0].parent
|
|
1455
|
+
self.progress.setRange(0, total)
|
|
1456
|
+
self.progress.setValue(0)
|
|
1457
|
+
self._set_status(f"Converting… 0 / {total} · 0.0 s")
|
|
1458
|
+
self._set_output_available(False)
|
|
1459
|
+
self.cancel_button.setEnabled(True)
|
|
1460
|
+
self._saved_files = []
|
|
1461
|
+
self._progress_done = 0
|
|
1462
|
+
self._progress_total = total
|
|
1463
|
+
self._session_elapsed = 0.0
|
|
1464
|
+
self._cancelling = False
|
|
1465
|
+
self._run_state = RunState.RUNNING
|
|
1466
|
+
self.worker = ConversionWorker(ConversionRequest(tuple(input_paths), self._options()))
|
|
1467
|
+
self.worker.log_received.connect(self._append_log)
|
|
1468
|
+
self.worker.paused_changed.connect(self._on_paused_changed)
|
|
1469
|
+
self.worker.progress_changed.connect(self._on_conversion_progress)
|
|
1470
|
+
self.worker.finished_conversion.connect(self._conversion_finished)
|
|
1471
|
+
self.worker.failed.connect(self._conversion_failed)
|
|
1472
|
+
self.worker.finished.connect(self._worker_stopped)
|
|
1473
|
+
self._update_buttons()
|
|
1474
|
+
self._session_started_at = time.perf_counter()
|
|
1475
|
+
self._append_log(
|
|
1476
|
+
LogEntry.create(
|
|
1477
|
+
"INFO",
|
|
1478
|
+
"conversion.started",
|
|
1479
|
+
f"Started {self.worker.request.conversion} conversion",
|
|
1480
|
+
{
|
|
1481
|
+
"profile": self.worker.request.conversion,
|
|
1482
|
+
"input_count": len(input_paths),
|
|
1483
|
+
"inputs": [path.name for path in input_paths],
|
|
1484
|
+
"target_count": total,
|
|
1485
|
+
},
|
|
1486
|
+
)
|
|
1487
|
+
)
|
|
1488
|
+
self._elapsed_timer.start()
|
|
1489
|
+
self.worker.start()
|
|
1490
|
+
self.conversion_dialog.open()
|
|
1491
|
+
|
|
1492
|
+
def _conversion_elapsed(self) -> float:
|
|
1493
|
+
if self._session_started_at is None:
|
|
1494
|
+
return self._session_elapsed
|
|
1495
|
+
return time.perf_counter() - self._session_started_at
|
|
1496
|
+
|
|
1497
|
+
def _finish_conversion_timer(self) -> None:
|
|
1498
|
+
self._elapsed_timer.stop()
|
|
1499
|
+
if self._session_started_at is not None:
|
|
1500
|
+
self._session_elapsed = time.perf_counter() - self._session_started_at
|
|
1501
|
+
self._session_started_at = None
|
|
1502
|
+
|
|
1503
|
+
def _refresh_progress_status(self) -> None:
|
|
1504
|
+
if self._cancelling or self._run_state in (RunState.PAUSING, RunState.PAUSED):
|
|
1505
|
+
return
|
|
1506
|
+
self._set_status(
|
|
1507
|
+
f"Converting… {self._progress_done} / {self._progress_total}"
|
|
1508
|
+
f" · {format_duration(self._conversion_elapsed())}"
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
def _on_conversion_progress(self, done: int, total: int) -> None:
|
|
1512
|
+
self.progress.setRange(0, total)
|
|
1513
|
+
self.progress.setValue(done)
|
|
1514
|
+
self._progress_done = done
|
|
1515
|
+
self._progress_total = total
|
|
1516
|
+
self._refresh_progress_status()
|
|
1517
|
+
|
|
1518
|
+
def _output_folder_to_open(self) -> Path | None:
|
|
1519
|
+
if self._run_output_directory is not None:
|
|
1520
|
+
return self._run_output_directory
|
|
1521
|
+
parents = {path.parent for path in self._saved_files}
|
|
1522
|
+
if parents:
|
|
1523
|
+
try:
|
|
1524
|
+
return Path(os.path.commonpath(parents))
|
|
1525
|
+
except ValueError:
|
|
1526
|
+
return min(parents)
|
|
1527
|
+
return self._run_input_directory
|
|
1528
|
+
|
|
1529
|
+
def _toggle_pause(self) -> None:
|
|
1530
|
+
if self.worker is None or self._cancelling:
|
|
1531
|
+
return
|
|
1532
|
+
if self._run_state is RunState.RUNNING:
|
|
1533
|
+
self._run_state = RunState.PAUSING
|
|
1534
|
+
self._set_status("Pausing… finishing the current font", "warning")
|
|
1535
|
+
self.pause_button.setEnabled(False)
|
|
1536
|
+
self.worker.pause()
|
|
1537
|
+
elif self._run_state is RunState.PAUSED:
|
|
1538
|
+
self.worker.resume()
|
|
1539
|
+
|
|
1540
|
+
def _on_paused_changed(self, paused: bool) -> None:
|
|
1541
|
+
if self.worker is None:
|
|
1542
|
+
return
|
|
1543
|
+
if paused:
|
|
1544
|
+
if self._run_state is not RunState.PAUSING or self._cancelling:
|
|
1545
|
+
return
|
|
1546
|
+
self._run_state = RunState.PAUSED
|
|
1547
|
+
self._finish_conversion_timer()
|
|
1548
|
+
self._set_status(
|
|
1549
|
+
f"Paused — {self._progress_done} / {self._progress_total}"
|
|
1550
|
+
f" · {format_duration(self._session_elapsed)}",
|
|
1551
|
+
"warning",
|
|
1552
|
+
)
|
|
1553
|
+
self._append_log(
|
|
1554
|
+
LogEntry.create(
|
|
1555
|
+
"INFO",
|
|
1556
|
+
"conversion.paused",
|
|
1557
|
+
f"Paused at {self._progress_done} / {self._progress_total}",
|
|
1558
|
+
{"done": self._progress_done, "total": self._progress_total},
|
|
1559
|
+
)
|
|
1560
|
+
)
|
|
1561
|
+
else:
|
|
1562
|
+
if self._run_state is not RunState.PAUSED:
|
|
1563
|
+
return
|
|
1564
|
+
self._run_state = RunState.RUNNING
|
|
1565
|
+
if not self._cancelling:
|
|
1566
|
+
self._session_started_at = time.perf_counter() - self._session_elapsed
|
|
1567
|
+
self._elapsed_timer.start()
|
|
1568
|
+
self._append_log(
|
|
1569
|
+
LogEntry.create("INFO", "conversion.resumed", "Resumed conversion", {})
|
|
1570
|
+
)
|
|
1571
|
+
self._refresh_progress_status()
|
|
1572
|
+
self._update_buttons()
|
|
1573
|
+
|
|
1574
|
+
def _cancel_conversion(self) -> None:
|
|
1575
|
+
if self.worker is None:
|
|
1576
|
+
return
|
|
1577
|
+
answer = QMessageBox.question(
|
|
1578
|
+
self.conversion_dialog,
|
|
1579
|
+
"Cancel conversion",
|
|
1580
|
+
"Cancel the conversion? The font being converted will be finished and the "
|
|
1581
|
+
"files already saved will be kept.",
|
|
1582
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1583
|
+
QMessageBox.StandardButton.No,
|
|
1584
|
+
)
|
|
1585
|
+
if answer != QMessageBox.StandardButton.Yes or self.worker is None:
|
|
1586
|
+
return
|
|
1587
|
+
self._request_cancel()
|
|
1588
|
+
|
|
1589
|
+
def _request_cancel(self) -> None:
|
|
1590
|
+
if self.worker is None:
|
|
1591
|
+
return
|
|
1592
|
+
self.cancel_button.setEnabled(False)
|
|
1593
|
+
self._cancelling = True
|
|
1594
|
+
self._set_status("Cancelling… finishing the current font", "warning")
|
|
1595
|
+
self.worker.cancel()
|
|
1596
|
+
self._update_buttons()
|
|
1597
|
+
|
|
1598
|
+
def _conversion_finished(self, result: ConversionResult) -> None:
|
|
1599
|
+
self._finish_conversion_timer()
|
|
1600
|
+
self._saved_files = result.saved_files
|
|
1601
|
+
self._last_run_available = True
|
|
1602
|
+
total_time = format_duration(self._session_elapsed)
|
|
1603
|
+
if result.cancelled:
|
|
1604
|
+
self._append_log(
|
|
1605
|
+
LogEntry.create(
|
|
1606
|
+
"WARNING",
|
|
1607
|
+
"conversion.cancelled",
|
|
1608
|
+
self._batch_summary(result),
|
|
1609
|
+
{"elapsed_seconds": self._session_elapsed},
|
|
1610
|
+
)
|
|
1611
|
+
)
|
|
1612
|
+
self._set_status(
|
|
1613
|
+
f"Cancelled — {self._batch_summary(result)} · {total_time}.", "warning"
|
|
1614
|
+
)
|
|
1615
|
+
self._set_output_available(bool(result.saved_files))
|
|
1616
|
+
return
|
|
1617
|
+
if not result.ok:
|
|
1618
|
+
failed_outcomes = [
|
|
1619
|
+
outcome for outcome in result.outcomes if outcome.kind is FontOutcomeKind.FAILED
|
|
1620
|
+
]
|
|
1621
|
+
failure_details = [
|
|
1622
|
+
f"{outcome.input_path.name}: {outcome.error or 'Unknown error'}"
|
|
1623
|
+
for outcome in failed_outcomes
|
|
1624
|
+
]
|
|
1625
|
+
message = result.error or "; ".join(failure_details) or "Conversion failed."
|
|
1626
|
+
error_types = sorted(
|
|
1627
|
+
{
|
|
1628
|
+
error_type
|
|
1629
|
+
for error_text in ([result.error] if result.error else [])
|
|
1630
|
+
+ [outcome.error for outcome in failed_outcomes if outcome.error]
|
|
1631
|
+
if (error_type := _exception_type(error_text)) is not None
|
|
1632
|
+
}
|
|
1633
|
+
)
|
|
1634
|
+
self._append_log(
|
|
1635
|
+
LogEntry.create(
|
|
1636
|
+
"ERROR",
|
|
1637
|
+
"conversion.failed",
|
|
1638
|
+
message,
|
|
1639
|
+
{
|
|
1640
|
+
"elapsed_seconds": self._session_elapsed,
|
|
1641
|
+
"converted": result.converted_count,
|
|
1642
|
+
"skipped": result.skipped_count,
|
|
1643
|
+
"failed": result.failed_count,
|
|
1644
|
+
"error_types": error_types,
|
|
1645
|
+
"failures": [
|
|
1646
|
+
{
|
|
1647
|
+
"input": outcome.input_path.name,
|
|
1648
|
+
"error_type": _exception_type(outcome.error or ""),
|
|
1649
|
+
"error": outcome.error,
|
|
1650
|
+
}
|
|
1651
|
+
for outcome in failed_outcomes
|
|
1652
|
+
],
|
|
1653
|
+
},
|
|
1654
|
+
)
|
|
1655
|
+
)
|
|
1656
|
+
if result.converted_count or result.failed_count:
|
|
1657
|
+
message += f" ({self._batch_summary(result)} · {total_time})"
|
|
1658
|
+
else:
|
|
1659
|
+
message += f" ({total_time})"
|
|
1660
|
+
self._show_error(message, log_event=None)
|
|
1661
|
+
self._set_output_available(bool(result.saved_files))
|
|
1662
|
+
return
|
|
1663
|
+
self._append_log(
|
|
1664
|
+
LogEntry.create(
|
|
1665
|
+
"INFO",
|
|
1666
|
+
"conversion.completed",
|
|
1667
|
+
self._batch_summary(result),
|
|
1668
|
+
{
|
|
1669
|
+
"elapsed_seconds": self._session_elapsed,
|
|
1670
|
+
"converted": result.converted_count,
|
|
1671
|
+
"skipped": result.skipped_count,
|
|
1672
|
+
"failed": result.failed_count,
|
|
1673
|
+
},
|
|
1674
|
+
)
|
|
1675
|
+
)
|
|
1676
|
+
self.progress.setValue(self.progress.maximum())
|
|
1677
|
+
self._set_status(
|
|
1678
|
+
f"Done — {self._batch_summary(result)} in {total_time}.",
|
|
1679
|
+
"warning" if result.failed_count else "",
|
|
1680
|
+
)
|
|
1681
|
+
self._set_output_available(bool(result.saved_files))
|
|
1682
|
+
|
|
1683
|
+
@staticmethod
|
|
1684
|
+
def _batch_summary(result: ConversionResult) -> str:
|
|
1685
|
+
return (
|
|
1686
|
+
f"{result.converted_count} converted, {result.skipped_count} skipped, "
|
|
1687
|
+
f"{result.failed_count} failed"
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
def _show_error(
|
|
1691
|
+
self,
|
|
1692
|
+
message: str,
|
|
1693
|
+
log_event: str | None = "application.error",
|
|
1694
|
+
log_context: dict[str, object] | None = None,
|
|
1695
|
+
) -> None:
|
|
1696
|
+
self._set_status(message, "error")
|
|
1697
|
+
if log_event is not None:
|
|
1698
|
+
self._append_log(
|
|
1699
|
+
LogEntry.create("ERROR", log_event, message, log_context or {"message": message})
|
|
1700
|
+
)
|
|
1701
|
+
self.progress.setRange(0, 1)
|
|
1702
|
+
self.progress.setValue(0)
|
|
1703
|
+
|
|
1704
|
+
def _conversion_failed(self, message: str) -> None:
|
|
1705
|
+
self._finish_conversion_timer()
|
|
1706
|
+
self._last_run_available = True
|
|
1707
|
+
self._show_error(
|
|
1708
|
+
f"Conversion failed: {message} ({format_duration(self._session_elapsed)})",
|
|
1709
|
+
log_event="conversion.failed",
|
|
1710
|
+
log_context={
|
|
1711
|
+
"error": message,
|
|
1712
|
+
"error_type": _exception_type(message),
|
|
1713
|
+
"elapsed_seconds": self._session_elapsed,
|
|
1714
|
+
},
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
def _worker_stopped(self) -> None:
|
|
1718
|
+
self._finish_conversion_timer()
|
|
1719
|
+
self.worker = None
|
|
1720
|
+
self._run_state = RunState.IDLE
|
|
1721
|
+
self._update_buttons()
|
|
1722
|
+
if self._close_when_finished:
|
|
1723
|
+
self.close()
|
|
1724
|
+
|
|
1725
|
+
def _open_output_folder(self) -> None:
|
|
1726
|
+
output_directory = self._output_folder_to_open()
|
|
1727
|
+
if output_directory is not None and not QDesktopServices.openUrl(
|
|
1728
|
+
QUrl.fromLocalFile(str(output_directory))
|
|
1729
|
+
):
|
|
1730
|
+
self._set_status("Could not open the output folder.", "error")
|
|
1731
|
+
|
|
1732
|
+
def closeEvent(self, event: QCloseEvent) -> None:
|
|
1733
|
+
if self.worker is not None and self.worker.isRunning():
|
|
1734
|
+
if self._run_state is RunState.PAUSED and not self._cancelling:
|
|
1735
|
+
box = QMessageBox(self)
|
|
1736
|
+
box.setWindowTitle("Conversion paused")
|
|
1737
|
+
box.setText("The conversion is paused. What do you want to do?")
|
|
1738
|
+
resume = box.addButton("Resume", QMessageBox.ButtonRole.AcceptRole)
|
|
1739
|
+
cancel = box.addButton("Cancel conversion", QMessageBox.ButtonRole.DestructiveRole)
|
|
1740
|
+
box.addButton("Keep window open", QMessageBox.ButtonRole.RejectRole)
|
|
1741
|
+
box.exec()
|
|
1742
|
+
clicked = box.clickedButton()
|
|
1743
|
+
if clicked is cancel:
|
|
1744
|
+
self._close_when_finished = True
|
|
1745
|
+
self._request_cancel()
|
|
1746
|
+
elif clicked is resume:
|
|
1747
|
+
self._toggle_pause()
|
|
1748
|
+
event.ignore()
|
|
1749
|
+
return
|
|
1750
|
+
if self._cancelling:
|
|
1751
|
+
self._close_when_finished = True
|
|
1752
|
+
event.ignore()
|
|
1753
|
+
return
|
|
1754
|
+
answer = QMessageBox.question(
|
|
1755
|
+
self,
|
|
1756
|
+
"Conversion in progress",
|
|
1757
|
+
"Wait for the current conversion to finish before closing?",
|
|
1758
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
1759
|
+
QMessageBox.StandardButton.Yes,
|
|
1760
|
+
)
|
|
1761
|
+
if answer == QMessageBox.StandardButton.Yes:
|
|
1762
|
+
self._close_when_finished = True
|
|
1763
|
+
self._set_status("The window will close when conversion finishes.", "warning")
|
|
1764
|
+
event.ignore()
|
|
1765
|
+
return
|
|
1766
|
+
self._stop_scan()
|
|
1767
|
+
self.file_list.shutdown()
|
|
1768
|
+
event.accept()
|