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,495 @@
1
+ """Reusable widgets for the NotYAFC window."""
2
+
3
+ from pathlib import Path
4
+
5
+ from PySide6.QtCore import QLocale, QSize, Qt, Signal
6
+ from PySide6.QtGui import (
7
+ QColor,
8
+ QDragEnterEvent,
9
+ QDragLeaveEvent,
10
+ QDragMoveEvent,
11
+ QDropEvent,
12
+ QIcon,
13
+ QKeyEvent,
14
+ QPainter,
15
+ QPainterPath,
16
+ QPaintEvent,
17
+ QPixmap,
18
+ QResizeEvent,
19
+ QValidator,
20
+ )
21
+ from PySide6.QtWidgets import (
22
+ QAbstractSpinBox,
23
+ QBoxLayout,
24
+ QButtonGroup,
25
+ QDoubleSpinBox,
26
+ QFrame,
27
+ QHBoxLayout,
28
+ QHeaderView,
29
+ QLabel,
30
+ QPushButton,
31
+ QRadioButton,
32
+ QSizePolicy,
33
+ QSpinBox,
34
+ QTableView,
35
+ QToolButton,
36
+ QVBoxLayout,
37
+ QWidget,
38
+ )
39
+
40
+ from font_converter.gui.styles import DARK, LIGHT
41
+
42
+
43
+ class ElidedLabel(QLabel):
44
+ """Single-line label that preserves its full text and tooltip when space is limited."""
45
+
46
+ def setText(self, text: str) -> None:
47
+ super().setText(text)
48
+ self.setToolTip(text)
49
+
50
+ def display_text(self) -> str:
51
+ return self.fontMetrics().elidedText(
52
+ self.text(), Qt.TextElideMode.ElideRight, self.contentsRect().width()
53
+ )
54
+
55
+ def paintEvent(self, event: QPaintEvent) -> None:
56
+ painter = QPainter(self)
57
+ rect = self.contentsRect()
58
+ self.style().drawItemText(
59
+ painter,
60
+ rect,
61
+ int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter),
62
+ self.palette(),
63
+ self.isEnabled(),
64
+ self.display_text(),
65
+ self.foregroundRole(),
66
+ )
67
+
68
+
69
+ COMMON_UPM_VALUES = (256, 512, 1000, 1024, 2000, 2048, 4096)
70
+ UPM_STEP_VALUES = (
71
+ 16,
72
+ 32,
73
+ 64,
74
+ 128,
75
+ 256,
76
+ 512,
77
+ 1000,
78
+ 1024,
79
+ 2000,
80
+ 2048,
81
+ 4000,
82
+ 4096,
83
+ 8000,
84
+ 8192,
85
+ 16000,
86
+ 16384,
87
+ )
88
+
89
+
90
+ def _theme_icon(symbol: str, color: QColor) -> QIcon:
91
+ pixmap = QPixmap(24, 24)
92
+ pixmap.fill(Qt.GlobalColor.transparent)
93
+ painter = QPainter(pixmap)
94
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
95
+ painter.setPen(color)
96
+ painter.setBrush(color)
97
+
98
+ if symbol == "sun":
99
+ painter.drawEllipse(9, 9, 6, 6)
100
+ painter.setPen(Qt.PenStyle.NoPen)
101
+ for angle in range(0, 360, 45):
102
+ painter.save()
103
+ painter.translate(12, 12)
104
+ painter.rotate(angle)
105
+ painter.drawRoundedRect(-1, -11, 2, 4, 1, 1)
106
+ painter.restore()
107
+ else:
108
+ crescent = QPainterPath()
109
+ crescent.addEllipse(4, 3, 16, 16)
110
+ cutout = QPainterPath()
111
+ cutout.addEllipse(10, 1, 14, 14)
112
+ painter.setPen(Qt.PenStyle.NoPen)
113
+ painter.drawPath(crescent.subtracted(cutout))
114
+
115
+ painter.end()
116
+ return QIcon(pixmap)
117
+
118
+
119
+ class ThemeToggle(QToolButton):
120
+ themeChanged = Signal(bool)
121
+
122
+ def __init__(self) -> None:
123
+ super().__init__()
124
+ self.setObjectName("themeToggle")
125
+ self.setFixedSize(92, 44)
126
+ self.setIconSize(QSize(82, 36))
127
+ self.setCheckable(True)
128
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
129
+ self.setAccessibleName("Dark theme")
130
+ self.toggled.connect(self._update_appearance)
131
+ self.clicked.connect(lambda checked: self.themeChanged.emit(checked))
132
+ self.set_dark_theme(False)
133
+
134
+ def set_dark_theme(self, enabled: bool) -> None:
135
+ self.setChecked(enabled)
136
+ self._update_appearance(enabled)
137
+
138
+ def _update_appearance(self, enabled: bool) -> None:
139
+ palette = DARK if enabled else LIGHT
140
+ current = "dark" if enabled else "light"
141
+ target = "light" if enabled else "dark"
142
+ self.setAccessibleDescription(f"Current theme: {current}. Activate to use {target} theme.")
143
+ self.setToolTip(f"Current theme: {current}. Switch to {target} theme.")
144
+
145
+ pixmap = QPixmap(82, 36)
146
+ pixmap.fill(Qt.GlobalColor.transparent)
147
+ painter = QPainter(pixmap)
148
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
149
+ painter.setPen(Qt.PenStyle.NoPen)
150
+ painter.setBrush(QColor(palette.toggle_checked))
151
+ painter.drawRoundedRect(44 if enabled else 2, 0, 36, 36, 18, 18)
152
+ for x, symbol, active in ((8, "sun", not enabled), (50, "moon", enabled)):
153
+ color = palette.icon_active if active else palette.icon_inactive
154
+ painter.drawPixmap(x, 6, _theme_icon(symbol, QColor(color)).pixmap(24, 24))
155
+ painter.end()
156
+ self.setIcon(QIcon(pixmap))
157
+
158
+
159
+ class KeyboardPushButton(QPushButton):
160
+ """Push button that activates on Return/Enter as well as Space."""
161
+
162
+ def keyPressEvent(self, event: QKeyEvent) -> None:
163
+ if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
164
+ if not event.isAutoRepeat():
165
+ self.click()
166
+ event.accept()
167
+ return
168
+ super().keyPressEvent(event)
169
+
170
+
171
+ class DecimalSpinBox(QDoubleSpinBox):
172
+ """Double spin box that accepts both '.' and ',' as decimal separator."""
173
+
174
+ def __init__(self) -> None:
175
+ super().__init__()
176
+ self.setLocale(QLocale.c())
177
+
178
+ def validate(self, text: str, pos: int) -> tuple[QValidator.State, str, int]:
179
+ return super().validate(text.replace(",", "."), pos)
180
+
181
+ def valueFromText(self, text: str) -> float:
182
+ return super().valueFromText(text.replace(",", "."))
183
+
184
+
185
+ class UpmSpinBox(QSpinBox):
186
+ """Spin box whose steps jump between supported units-per-em values."""
187
+
188
+ def __init__(self) -> None:
189
+ super().__init__()
190
+ self.setRange(16, 16384)
191
+ self.setValue(1000)
192
+
193
+ def stepBy(self, steps: int) -> None:
194
+ value = self.value()
195
+ for _ in range(abs(steps)):
196
+ if steps > 0:
197
+ value = next((v for v in UPM_STEP_VALUES if v > value), self.maximum())
198
+ else:
199
+ value = next((v for v in reversed(UPM_STEP_VALUES) if v < value), self.minimum())
200
+ self.setValue(value)
201
+
202
+
203
+ class StepperField(QWidget):
204
+ """Spin box flanked by app-styled − / + buttons instead of native arrows."""
205
+
206
+ def __init__(self, spin_box: QAbstractSpinBox, *, tab_buttons: bool = False) -> None:
207
+ super().__init__()
208
+ self.spin_box = spin_box
209
+ spin_box.setAlignment(Qt.AlignmentFlag.AlignCenter)
210
+ layout = QHBoxLayout(self)
211
+ layout.setContentsMargins(0, 0, 0, 0)
212
+ layout.setSpacing(6)
213
+ self.decrement_button = KeyboardPushButton("−")
214
+ self.increment_button = KeyboardPushButton("+")
215
+ for button, direction in ((self.decrement_button, -1), (self.increment_button, 1)):
216
+ button.setObjectName("stepperButton")
217
+ button.setAutoRepeat(True)
218
+ if not tab_buttons:
219
+ button.setFocusPolicy(Qt.FocusPolicy.NoFocus)
220
+ button.clicked.connect(lambda _=False, step=direction: spin_box.stepBy(step))
221
+ layout.addWidget(self.decrement_button)
222
+ layout.addWidget(spin_box, 1)
223
+ layout.addWidget(self.increment_button)
224
+
225
+
226
+ class ConversionSelector(QWidget):
227
+ currentIndexChanged = Signal(int)
228
+
229
+ def __init__(
230
+ self,
231
+ conversions: dict[str, str],
232
+ *,
233
+ select_first: bool = True,
234
+ group_headings: dict[str, str] | None = None,
235
+ ) -> None:
236
+ super().__init__()
237
+ self._keys = list(conversions)
238
+ self._labels = list(conversions.values())
239
+ self._group = QButtonGroup(self)
240
+ layout = QVBoxLayout(self)
241
+ layout.setContentsMargins(0, 0, 0, 0)
242
+ layout.setSpacing(8)
243
+ for index, (key, label) in enumerate(conversions.items()):
244
+ if group_headings is not None and key in group_headings:
245
+ if index:
246
+ layout.addSpacing(3)
247
+ heading = QLabel(group_headings[key])
248
+ heading.setObjectName("conversionGroupHeading")
249
+ layout.addWidget(heading)
250
+ button = QRadioButton(label)
251
+ self._group.addButton(button, index)
252
+ layout.addWidget(button)
253
+ layout.addStretch()
254
+ if select_first:
255
+ self._group.button(0).setChecked(True)
256
+ self._group.idClicked.connect(self.currentIndexChanged)
257
+
258
+ def currentIndex(self) -> int:
259
+ return self._group.checkedId()
260
+
261
+ def currentData(self) -> str | None:
262
+ index = self.currentIndex()
263
+ return self._keys[index] if index >= 0 else None
264
+
265
+ def findData(self, key: str) -> int:
266
+ return self._keys.index(key) if key in self._keys else -1
267
+
268
+ def setCurrentIndex(self, index: int) -> None:
269
+ button = self._group.button(index)
270
+ if button is not None and not button.isChecked():
271
+ button.setChecked(True)
272
+ self.currentIndexChanged.emit(index)
273
+
274
+ def set_compatibility_counts(self, counts: dict[str, int] | None, total: int) -> None:
275
+ for index, (key, label) in enumerate(zip(self._keys, self._labels, strict=True)):
276
+ button = self._group.button(index)
277
+ if button is not None:
278
+ button.setText(
279
+ f"{label} · {counts[key]}/{total} compatible" if counts is not None else label
280
+ )
281
+ layout = self.layout()
282
+ if layout is not None:
283
+ layout.invalidate()
284
+ self.updateGeometry()
285
+
286
+
287
+ def accept_local_files(event: QDragEnterEvent | QDragMoveEvent) -> None:
288
+ if event.mimeData().hasUrls() and any(url.isLocalFile() for url in event.mimeData().urls()):
289
+ event.acceptProposedAction()
290
+ else:
291
+ event.ignore()
292
+
293
+
294
+ def dropped_paths(event: QDropEvent) -> list[str]:
295
+ return [
296
+ url.toLocalFile()
297
+ for url in event.mimeData().urls()
298
+ if url.isLocalFile()
299
+ and (Path(url.toLocalFile()).is_file() or Path(url.toLocalFile()).is_dir())
300
+ ]
301
+
302
+
303
+ def set_drag_over(widget: QWidget, enabled: bool) -> None:
304
+ value = "true" if enabled else "false"
305
+ if widget.property("dragOver") == value:
306
+ return
307
+ widget.setProperty("dragOver", value)
308
+ widget.style().unpolish(widget)
309
+ widget.style().polish(widget)
310
+ widget.update()
311
+
312
+
313
+ class DropIcon(QWidget):
314
+ def __init__(self) -> None:
315
+ super().__init__()
316
+ self.setFixedSize(32, 32)
317
+ self._color = QColor(LIGHT.text_muted)
318
+
319
+ def set_dark_theme(self, enabled: bool) -> None:
320
+ self._color = QColor((DARK if enabled else LIGHT).text_muted)
321
+ self.update()
322
+
323
+ def paintEvent(self, event: QPaintEvent) -> None:
324
+ painter = QPainter(self)
325
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
326
+ painter.setPen(self._color)
327
+ pen = painter.pen()
328
+ pen.setWidth(2)
329
+ pen.setCapStyle(Qt.PenCapStyle.RoundCap)
330
+ pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
331
+ painter.setPen(pen)
332
+ painter.drawLine(16, 3, 16, 20)
333
+ painter.drawLine(10, 14, 16, 20)
334
+ painter.drawLine(16, 20, 22, 14)
335
+ painter.drawLine(6, 21, 6, 27)
336
+ painter.drawLine(6, 27, 26, 27)
337
+ painter.drawLine(26, 27, 26, 21)
338
+ painter.end()
339
+
340
+
341
+ class DropZone(QFrame):
342
+ paths_dropped = Signal(list)
343
+ addFilesRequested = Signal()
344
+ addFolderRequested = Signal()
345
+
346
+ def __init__(self) -> None:
347
+ super().__init__()
348
+ self.setObjectName("dropZone")
349
+ self.setAccessibleName("Font drop area")
350
+ self.setAcceptDrops(True)
351
+ self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding)
352
+ self.setProperty("dragOver", "false")
353
+ self._scanning = False
354
+ layout = QVBoxLayout(self)
355
+ layout.setContentsMargins(12, 12, 12, 12)
356
+ layout.setSpacing(5)
357
+ layout.addStretch()
358
+ self.icon = DropIcon()
359
+ layout.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignHCenter)
360
+ self.title = QLabel("Drop fonts or folders here")
361
+ self.title.setObjectName("dropZoneTitle")
362
+ self.title.setAlignment(Qt.AlignmentFlag.AlignCenter)
363
+ self.title.setWordWrap(True)
364
+ self.title.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
365
+ layout.addWidget(self.title)
366
+ self.formats = QLabel("TTF · OTF · WOFF · WOFF2 · TTC · OTC")
367
+ self.folder_hint = QLabel("Folders are scanned for fonts")
368
+ for label in (self.formats, self.folder_hint):
369
+ label.setObjectName("hint")
370
+ label.setAlignment(Qt.AlignmentFlag.AlignCenter)
371
+ label.setWordWrap(True)
372
+ label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
373
+ layout.addWidget(label)
374
+ self.buttons = QWidget()
375
+ self.buttons_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, self.buttons)
376
+ self.buttons_layout.setContentsMargins(0, 0, 0, 0)
377
+ self.buttons_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
378
+ self.add_files_button = KeyboardPushButton("Add files…")
379
+ self.add_folder_button = KeyboardPushButton("Add folder…")
380
+ self.buttons_layout.addWidget(self.add_files_button)
381
+ self.buttons_layout.addWidget(self.add_folder_button)
382
+ self.add_files_button.clicked.connect(self.addFilesRequested.emit)
383
+ self.add_folder_button.clicked.connect(self.addFolderRequested.emit)
384
+ layout.addWidget(self.buttons)
385
+ layout.addStretch()
386
+ for widget in (self.formats, self.folder_hint, self.buttons):
387
+ policy = widget.sizePolicy()
388
+ policy.setRetainSizeWhenHidden(True)
389
+ widget.setSizePolicy(policy)
390
+
391
+ def set_dark_theme(self, enabled: bool) -> None:
392
+ self.icon.set_dark_theme(enabled)
393
+
394
+ def resizeEvent(self, event: QResizeEvent) -> None:
395
+ super().resizeEvent(event)
396
+ required = (
397
+ self.add_files_button.sizeHint().width()
398
+ + self.add_folder_button.sizeHint().width()
399
+ + self.buttons_layout.spacing()
400
+ )
401
+ self.buttons_layout.setDirection(
402
+ QBoxLayout.Direction.LeftToRight
403
+ if self.contentsRect().width() - 24 >= required
404
+ else QBoxLayout.Direction.TopToBottom
405
+ )
406
+
407
+ def set_scanning(self, scanning: bool) -> None:
408
+ self._scanning = scanning
409
+ self._update_state()
410
+
411
+ def _update_state(self) -> None:
412
+ dragging = self.property("dragOver") == "true"
413
+ self.title.setText(
414
+ "Release to add"
415
+ if dragging
416
+ else "Scanning…"
417
+ if self._scanning
418
+ else "Drop fonts or folders here"
419
+ )
420
+ for widget in (self.formats, self.folder_hint, self.buttons):
421
+ widget.setVisible(not (dragging or self._scanning))
422
+
423
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
424
+ accept_local_files(event)
425
+ set_drag_over(self, event.isAccepted())
426
+ self._update_state()
427
+
428
+ def dragMoveEvent(self, event: QDragMoveEvent) -> None:
429
+ accept_local_files(event)
430
+
431
+ def dragLeaveEvent(self, event: QDragLeaveEvent) -> None:
432
+ set_drag_over(self, False)
433
+ self._update_state()
434
+ event.accept()
435
+
436
+ def dropEvent(self, event: QDropEvent) -> None:
437
+ set_drag_over(self, False)
438
+ self._update_state()
439
+ paths = dropped_paths(event)
440
+ if paths:
441
+ event.acceptProposedAction()
442
+ self.paths_dropped.emit(paths)
443
+ else:
444
+ event.ignore()
445
+
446
+
447
+ class FileTable(QTableView):
448
+ paths_dropped = Signal(list)
449
+
450
+ def __init__(self) -> None:
451
+ super().__init__()
452
+ self.setAcceptDrops(True)
453
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
454
+ self.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
455
+ self.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
456
+ self.setTabKeyNavigation(False)
457
+ self.setSortingEnabled(True)
458
+ self.setAlternatingRowColors(True)
459
+ self.setProperty("dragOver", "false")
460
+ self.muted_color = QColor(LIGHT.text_disabled)
461
+
462
+ def set_dark_theme(self, enabled: bool) -> None:
463
+ palette = DARK if enabled else LIGHT
464
+ self.muted_color = QColor(palette.text_disabled)
465
+ self.viewport().update()
466
+
467
+ def configure_columns(self) -> None:
468
+ header = self.horizontalHeader()
469
+ header.setStretchLastSection(False)
470
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
471
+ for column, width in ((1, 80), (2, 110), (3, 70), (4, 140), (5, 140)):
472
+ header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive)
473
+ header.resizeSection(column, width)
474
+ for column in (4, 5):
475
+ self.setColumnHidden(column, True)
476
+
477
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
478
+ accept_local_files(event)
479
+ set_drag_over(self, event.isAccepted())
480
+
481
+ def dragMoveEvent(self, event: QDragMoveEvent) -> None:
482
+ accept_local_files(event)
483
+
484
+ def dragLeaveEvent(self, event: QDragLeaveEvent) -> None:
485
+ set_drag_over(self, False)
486
+ event.accept()
487
+
488
+ def dropEvent(self, event: QDropEvent) -> None:
489
+ set_drag_over(self, False)
490
+ paths = dropped_paths(event)
491
+ if paths:
492
+ self.paths_dropped.emit(paths)
493
+ event.acceptProposedAction()
494
+ else:
495
+ event.ignore()