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.
@@ -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()