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,411 @@
1
+ """Model-backed font table, filtering and scan coordination."""
2
+
3
+ from collections.abc import Callable, Iterator
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from PySide6.QtCore import (
8
+ QAbstractTableModel,
9
+ QModelIndex,
10
+ QObject,
11
+ QPersistentModelIndex,
12
+ QSortFilterProxyModel,
13
+ Qt,
14
+ Signal,
15
+ )
16
+ from PySide6.QtGui import QBrush, QColor, QPainter
17
+ from PySide6.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem
18
+
19
+ from font_converter.gui.scanner import ScanWorker
20
+ from font_converter.gui.styles import LIGHT
21
+ from font_converter.gui.widgets import FileTable
22
+ from font_converter.inspection import FontInfo, incompatibility
23
+
24
+ _PATH_ROLE = Qt.ItemDataRole.UserRole
25
+ _REASON_ROLE = Qt.ItemDataRole.UserRole + 1
26
+ _INFO_ROLE = Qt.ItemDataRole.UserRole + 2
27
+ _ROOT_INDEX = QModelIndex()
28
+
29
+
30
+ @dataclass
31
+ class FontRow:
32
+ path: Path
33
+ label: str
34
+ format: str
35
+ outlines: str
36
+ upm: str
37
+ info: FontInfo
38
+ checked: bool
39
+ reason: str | None = None
40
+
41
+
42
+ class FontTableModel(QAbstractTableModel):
43
+ HEADERS = ("Name", "Format", "Outlines", "UPM", "Family", "Style")
44
+
45
+ def __init__(self, parent=None) -> None:
46
+ super().__init__(parent)
47
+ self.rows: list[FontRow] = []
48
+
49
+ def rowCount(self, parent: QModelIndex | QPersistentModelIndex = _ROOT_INDEX) -> int:
50
+ return 0 if parent.isValid() else len(self.rows)
51
+
52
+ def columnCount(self, parent: QModelIndex | QPersistentModelIndex = _ROOT_INDEX) -> int:
53
+ return 0 if parent.isValid() else len(self.HEADERS)
54
+
55
+ def headerData(self, section: int, orientation: Qt.Orientation, role: int = 0):
56
+ if role == Qt.ItemDataRole.DisplayRole and orientation == Qt.Orientation.Horizontal:
57
+ return self.HEADERS[section]
58
+ if role == Qt.ItemDataRole.TextAlignmentRole and orientation == Qt.Orientation.Horizontal:
59
+ if section == 0:
60
+ return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
61
+ if section == 3:
62
+ return Qt.AlignmentFlag.AlignCenter
63
+ return None
64
+
65
+ def data(self, index: QModelIndex | QPersistentModelIndex, role: int = 0):
66
+ if not index.isValid():
67
+ return None
68
+ row = self.rows[index.row()]
69
+ column = index.column()
70
+ if role == Qt.ItemDataRole.DisplayRole:
71
+ return (row.label, row.format, row.outlines, row.upm, row.info.family, row.info.style)[
72
+ column
73
+ ]
74
+ if role == Qt.ItemDataRole.TextAlignmentRole and column == 3:
75
+ return Qt.AlignmentFlag.AlignCenter
76
+ if role == Qt.ItemDataRole.CheckStateRole and column == 0:
77
+ checked = row.checked and row.reason is None
78
+ return Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
79
+ if role == _PATH_ROLE:
80
+ return str(row.path)
81
+ if role == _REASON_ROLE:
82
+ return row.reason
83
+ if role == _INFO_ROLE:
84
+ return row.info
85
+ if role == Qt.ItemDataRole.ToolTipRole:
86
+ return f"{row.path}\n{row.reason}" if row.reason else str(row.path)
87
+ if role == Qt.ItemDataRole.ForegroundRole and row.reason:
88
+ view = self.parent()
89
+ muted = view.muted_color if isinstance(view, FileTable) else QColor(LIGHT.text_disabled)
90
+ return QBrush(muted)
91
+ return None
92
+
93
+ def flags(self, index: QModelIndex | QPersistentModelIndex) -> Qt.ItemFlag:
94
+ if not index.isValid():
95
+ return Qt.ItemFlag.NoItemFlags
96
+ flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
97
+ row = self.rows[index.row()]
98
+ if index.column() == 0 and row.reason is None:
99
+ flags |= Qt.ItemFlag.ItemIsUserCheckable
100
+ return flags
101
+
102
+ def setData(self, index: QModelIndex | QPersistentModelIndex, value, role: int = 0) -> bool:
103
+ if (
104
+ not index.isValid()
105
+ or index.column() != 0
106
+ or role != Qt.ItemDataRole.CheckStateRole
107
+ or self.rows[index.row()].reason is not None
108
+ ):
109
+ return False
110
+ checked = value in (Qt.CheckState.Checked, Qt.CheckState.Checked.value)
111
+ row = self.rows[index.row()]
112
+ if row.checked == checked:
113
+ return True
114
+ row.checked = checked
115
+ self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole])
116
+ return True
117
+
118
+ def add_font(
119
+ self,
120
+ path: Path,
121
+ label: str,
122
+ info: FontInfo,
123
+ checked: bool,
124
+ profile: tuple[str | None, bool],
125
+ ) -> None:
126
+ conversion, _ = profile
127
+ reason = incompatibility(info, conversion) if conversion is not None else None
128
+ row = FontRow(
129
+ path,
130
+ label,
131
+ f"TTC ({info.num_fonts})" if info.collection else info.format,
132
+ info.outlines,
133
+ "Mixed" if info.upm is None else str(info.upm),
134
+ info,
135
+ checked,
136
+ reason,
137
+ )
138
+ position = len(self.rows)
139
+ self.beginInsertRows(QModelIndex(), position, position)
140
+ self.rows.append(row)
141
+ self.endInsertRows()
142
+
143
+ def clear(self) -> None:
144
+ self.beginResetModel()
145
+ self.rows.clear()
146
+ self.endResetModel()
147
+
148
+ def set_checks(self, source_rows: list[int], checked: bool) -> None:
149
+ changed_rows = []
150
+ for source_row in source_rows:
151
+ row = self.rows[source_row]
152
+ if row.reason is None and row.checked != checked:
153
+ row.checked = checked
154
+ changed_rows.append(source_row)
155
+ if changed_rows:
156
+ self.dataChanged.emit(
157
+ self.index(min(changed_rows), 0),
158
+ self.index(max(changed_rows), 0),
159
+ [Qt.ItemDataRole.CheckStateRole],
160
+ )
161
+
162
+ def update_compatibility(self, profile: tuple[str | None, bool]) -> None:
163
+ conversion, _ = profile
164
+ for row in self.rows:
165
+ row.reason = incompatibility(row.info, conversion) if conversion is not None else None
166
+ if self.rows:
167
+ self.dataChanged.emit(
168
+ self.index(0, 0),
169
+ self.index(len(self.rows) - 1, self.columnCount() - 1),
170
+ [
171
+ Qt.ItemDataRole.DisplayRole,
172
+ Qt.ItemDataRole.ToolTipRole,
173
+ Qt.ItemDataRole.ForegroundRole,
174
+ Qt.ItemDataRole.CheckStateRole,
175
+ ],
176
+ )
177
+
178
+
179
+ class FontNameDelegate(QStyledItemDelegate):
180
+ def paint(
181
+ self,
182
+ painter: QPainter,
183
+ option: QStyleOptionViewItem,
184
+ index: QModelIndex | QPersistentModelIndex,
185
+ ) -> None:
186
+ reason = index.data(_REASON_ROLE)
187
+ display_option = QStyleOptionViewItem(option)
188
+ if reason:
189
+ display_option.state &= ~QStyle.StateFlag.State_Enabled
190
+ super().paint(painter, display_option, index)
191
+ if not isinstance(reason, str) or not reason:
192
+ return
193
+ self.initStyleOption(display_option, index)
194
+ view = self.parent()
195
+ if not isinstance(view, FileTable):
196
+ return
197
+ rect = view.style().subElementRect(
198
+ QStyle.SubElement.SE_ItemViewItemText, display_option, view
199
+ )
200
+ metrics = display_option.fontMetrics
201
+ name_width = metrics.horizontalAdvance(str(index.data(Qt.ItemDataRole.DisplayRole) or ""))
202
+ rect.setLeft(rect.left() + name_width + 12)
203
+ rect.adjust(0, 0, -4, 0)
204
+ if rect.width() < 60:
205
+ return
206
+ text = metrics.elidedText(reason, Qt.TextElideMode.ElideRight, rect.width())
207
+ painter.save()
208
+ painter.setClipRect(option.rect)
209
+ painter.setFont(display_option.font)
210
+ painter.setPen(view.muted_color)
211
+ painter.drawText(rect, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter, text)
212
+ painter.restore()
213
+
214
+
215
+ class FontFilterProxyModel(QSortFilterProxyModel):
216
+ def __init__(self, parent=None) -> None:
217
+ super().__init__(parent)
218
+ self.hide_incompatible = False
219
+ self.setFilterKeyColumn(-1)
220
+ self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
221
+
222
+ def filterAcceptsRow(
223
+ self, source_row: int, source_parent: QModelIndex | QPersistentModelIndex
224
+ ) -> bool:
225
+ if not super().filterAcceptsRow(source_row, source_parent):
226
+ return False
227
+ if not self.hide_incompatible:
228
+ return True
229
+ model = self.sourceModel()
230
+ index = model.index(source_row, 0, source_parent) if model is not None else QModelIndex()
231
+ return not index.isValid() or index.data(_REASON_ROLE) is None
232
+
233
+ def lessThan(
234
+ self,
235
+ left: QModelIndex | QPersistentModelIndex,
236
+ right: QModelIndex | QPersistentModelIndex,
237
+ ) -> bool:
238
+ left_value = str(left.data(Qt.ItemDataRole.DisplayRole) or "")
239
+ right_value = str(right.data(Qt.ItemDataRole.DisplayRole) or "")
240
+ if left.column() == 3:
241
+ try:
242
+ return int(left_value) < int(right_value)
243
+ except ValueError:
244
+ pass
245
+ return left_value.casefold() < right_value.casefold()
246
+
247
+
248
+ class FontFileList(QObject):
249
+ """Own the model-backed font table and its asynchronous scan."""
250
+
251
+ progress = Signal(int, int)
252
+ font_found = Signal(object, object)
253
+ scan_finished = Signal()
254
+
255
+ def __init__(
256
+ self,
257
+ view: FileTable,
258
+ profile: Callable[[], tuple[str | None, bool]],
259
+ ) -> None:
260
+ super().__init__(view)
261
+ self.view = view
262
+ self.profile = profile
263
+ self.model = FontTableModel(view)
264
+ self.proxy = FontFilterProxyModel(view)
265
+ self.proxy.setSourceModel(self.model)
266
+ self.view.setModel(self.proxy)
267
+ self.view.setItemDelegateForColumn(0, FontNameDelegate(self.view))
268
+ self.view.configure_columns()
269
+ self.view.sortByColumn(0, Qt.SortOrder.AscendingOrder)
270
+ self._worker: ScanWorker | None = None
271
+ self._retired_workers: list[ScanWorker] = []
272
+ self._seen: set[Path] = set()
273
+
274
+ @property
275
+ def scanning(self) -> bool:
276
+ return self._worker is not None
277
+
278
+ @property
279
+ def row_count(self) -> int:
280
+ return len(self.model.rows)
281
+
282
+ def start(self, sources: list[Path], recursive: bool) -> None:
283
+ self.stop()
284
+ self._seen.clear()
285
+ worker = ScanWorker(sources, recursive)
286
+ self._worker = worker
287
+ # Lambdas have no receiver thread affinity: force queued delivery to the GUI thread.
288
+ queued = Qt.ConnectionType.QueuedConnection
289
+ worker.progress.connect(
290
+ lambda done, total, w=worker: self._on_progress(w, done, total), queued
291
+ )
292
+ worker.font_found.connect(
293
+ lambda path, info, w=worker: self._on_font_found(w, path, info), queued
294
+ )
295
+ worker.finished.connect(lambda w=worker: self._on_finished(w), queued)
296
+ worker.start()
297
+
298
+ def stop(self) -> None:
299
+ worker = self._worker
300
+ if worker is None:
301
+ return
302
+ self._worker = None
303
+ worker.requestInterruption()
304
+ if worker.isRunning():
305
+ self._retired_workers.append(worker)
306
+
307
+ def cancel(self) -> None:
308
+ if self._worker is not None:
309
+ self._worker.requestInterruption()
310
+
311
+ def shutdown(self) -> None:
312
+ self.stop()
313
+ for worker in self._retired_workers:
314
+ worker.wait()
315
+ self._retired_workers.clear()
316
+
317
+ def clear(self) -> None:
318
+ self.model.clear()
319
+
320
+ def add_font(self, path: Path, label: str, info: FontInfo, checked: bool) -> None:
321
+ self.model.add_font(path, label, info, checked, self.profile())
322
+
323
+ def apply_compatibility(self, *_: object) -> None:
324
+ profile = self.profile()
325
+ self.model.update_compatibility(profile)
326
+ self.proxy.hide_incompatible = profile[1]
327
+ if hasattr(self.proxy, "beginFilterChange"):
328
+ self.proxy.beginFilterChange()
329
+ self.proxy.endFilterChange(QSortFilterProxyModel.Direction.Rows)
330
+ else:
331
+ self.proxy.invalidateFilter()
332
+
333
+ def rows(self) -> Iterator[FontRow]:
334
+ return iter(self.model.rows)
335
+
336
+ def path_for_index(self, index: QModelIndex) -> Path:
337
+ source = self.proxy.mapToSource(index)
338
+ return self.model.rows[source.row()].path
339
+
340
+ def index_for_path(self, path: Path) -> QModelIndex:
341
+ for source_row, row in enumerate(self.model.rows):
342
+ if row.path == path:
343
+ return self.proxy.mapFromSource(self.model.index(source_row, 0))
344
+ return QModelIndex()
345
+
346
+ def is_visible(self, path: Path) -> bool:
347
+ return self.index_for_path(path).isValid()
348
+
349
+ def selected_paths(self) -> list[Path]:
350
+ return [
351
+ row.path
352
+ for proxy_row in range(self.proxy.rowCount())
353
+ if (
354
+ (source := self.proxy.mapToSource(self.proxy.index(proxy_row, 0))).isValid()
355
+ and (row := self.model.rows[source.row()]).checked
356
+ and row.reason is None
357
+ )
358
+ ]
359
+
360
+ def remember_checks(self) -> dict[Path, bool]:
361
+ return {row.path: row.checked for row in self.model.rows}
362
+
363
+ def set_visible_checks(self, checked: bool) -> None:
364
+ source_rows = []
365
+ for proxy_row in range(self.proxy.rowCount()):
366
+ proxy_index = self.proxy.index(proxy_row, 0)
367
+ source_rows.append(self.proxy.mapToSource(proxy_index).row())
368
+ self.model.set_checks(source_rows, checked)
369
+
370
+ def compatible_count(self) -> int:
371
+ return sum(1 for row in self.model.rows if row.reason is None)
372
+
373
+ def visible_check_state(self) -> Qt.CheckState | None:
374
+ compatible = 0
375
+ checked = 0
376
+ for proxy_row in range(self.proxy.rowCount()):
377
+ source = self.proxy.mapToSource(self.proxy.index(proxy_row, 0))
378
+ if not source.isValid():
379
+ continue
380
+ row = self.model.rows[source.row()]
381
+ if row.reason is None:
382
+ compatible += 1
383
+ checked += row.checked
384
+ if compatible == 0:
385
+ return None
386
+ if checked == 0:
387
+ return Qt.CheckState.Unchecked
388
+ if checked == compatible:
389
+ return Qt.CheckState.Checked
390
+ return Qt.CheckState.PartiallyChecked
391
+
392
+ def _on_progress(self, worker: ScanWorker, done: int, total: int) -> None:
393
+ if worker is self._worker:
394
+ self.progress.emit(done, total)
395
+
396
+ def _on_font_found(self, worker: ScanWorker, path: Path, info: FontInfo) -> None:
397
+ if worker is self._worker and path not in self._seen:
398
+ self._seen.add(path)
399
+ self.font_found.emit(path, info)
400
+
401
+ def _on_finished(self, worker: ScanWorker) -> None:
402
+ worker.wait()
403
+ for signal in (worker.progress, worker.font_found, worker.finished):
404
+ signal.disconnect()
405
+ if worker in self._retired_workers:
406
+ self._retired_workers.remove(worker)
407
+ return
408
+ if worker is not self._worker:
409
+ return
410
+ self._worker = None
411
+ self.scan_finished.emit()