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,417 @@
1
+ """Dialog for composing output-directory organization rules."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from html import escape
5
+
6
+ from PySide6.QtCore import Qt
7
+ from PySide6.QtGui import QStandardItem, QStandardItemModel
8
+ from PySide6.QtWidgets import (
9
+ QComboBox,
10
+ QDialog,
11
+ QDialogButtonBox,
12
+ QFrame,
13
+ QGridLayout,
14
+ QHBoxLayout,
15
+ QLabel,
16
+ QPushButton,
17
+ QVBoxLayout,
18
+ QWidget,
19
+ )
20
+
21
+ from font_converter.gui.widgets import KeyboardPushButton
22
+ from font_converter.service import organization_level_name
23
+
24
+ CRITERIA: dict[str, str] = {
25
+ "family": "Family",
26
+ "format": "Format",
27
+ "outline": "Outline type",
28
+ "upm": "UPM",
29
+ "tolerance": "Tolerance",
30
+ "simplify": "Contour simplification",
31
+ }
32
+
33
+ _PROFILE_CRITERIA: dict[str, tuple[str, ...]] = {
34
+ "ttf2otf": ("family", "format", "outline", "upm", "tolerance", "simplify"),
35
+ "otf2ttf": ("family", "format", "outline", "upm", "tolerance"),
36
+ "ft2wf": ("family", "format", "outline"),
37
+ "woff2ft": ("family", "format", "outline"),
38
+ "woff22ft": ("family", "format", "outline"),
39
+ "ttc2sfnt": ("family", "format", "outline"),
40
+ "var2static": ("family", "format", "outline", "upm"),
41
+ }
42
+
43
+
44
+ @dataclass
45
+ class _LevelRow:
46
+ widget: QFrame
47
+ number: QLabel
48
+ criteria_layout: QGridLayout
49
+ add_criterion: QPushButton
50
+ move_up: QPushButton
51
+ move_down: QPushButton
52
+ remove_level: QPushButton
53
+ combos: list[QComboBox] = field(default_factory=list)
54
+ criterion_moves: dict[QComboBox, tuple[QPushButton, QPushButton]] = field(default_factory=dict)
55
+ criterion_removes: dict[QComboBox, QPushButton] = field(default_factory=dict)
56
+
57
+
58
+ def available_organization_criteria(conversion: str) -> tuple[str, ...]:
59
+ return _PROFILE_CRITERIA.get(conversion, ("family", "format", "outline"))
60
+
61
+
62
+ class OrganizationDialog(QDialog):
63
+ """Configure ordered directory levels, with multiple criteria per level."""
64
+
65
+ def __init__(
66
+ self,
67
+ conversion: str,
68
+ levels: tuple[tuple[str, ...], ...],
69
+ values: dict[str, str],
70
+ extension: str,
71
+ parent: QWidget | None = None,
72
+ ) -> None:
73
+ super().__init__(parent)
74
+ self.setWindowTitle("Output organization")
75
+ self.setMinimumWidth(680)
76
+ self.resize(800, 360)
77
+ self._available = available_organization_criteria(conversion)
78
+ self._values = values
79
+ self._extension = extension
80
+ self._rows: list[_LevelRow] = []
81
+ self._previous: dict[QComboBox, str] = {}
82
+ self._models: dict[QComboBox, QStandardItemModel] = {}
83
+
84
+ layout = QVBoxLayout(self)
85
+ layout.setContentsMargins(22, 18, 22, 18)
86
+ layout.setSpacing(12)
87
+ intro = QLabel(
88
+ "Choose font properties to organize your output. Criteria in the same level are "
89
+ "joined with “_”; separate levels create nested folders, like family_format/outline."
90
+ )
91
+ intro.setObjectName("organizationIntro")
92
+ intro.setWordWrap(True)
93
+ layout.addWidget(intro)
94
+ levels_title = QLabel("FOLDER LEVELS")
95
+ levels_title.setObjectName("organizationSection")
96
+ layout.addWidget(levels_title)
97
+ self._levels_layout = QVBoxLayout()
98
+ self._levels_layout.setSpacing(7)
99
+ layout.addLayout(self._levels_layout)
100
+ self.empty_hint = QLabel(
101
+ "No folder levels: files will be saved directly in the chosen output location."
102
+ )
103
+ self.empty_hint.setObjectName("hint")
104
+ self.empty_hint.setWordWrap(True)
105
+ layout.addWidget(self.empty_hint)
106
+
107
+ actions = QHBoxLayout()
108
+ self.add_level_button = KeyboardPushButton("+ Add folder level")
109
+ self.add_level_button.setObjectName("organizationAddButton")
110
+ self.add_level_button.clicked.connect(lambda: self._add_level())
111
+ actions.addWidget(self.add_level_button)
112
+ actions.addStretch()
113
+ layout.addLayout(actions)
114
+ self.criteria_exhausted_hint = QLabel(
115
+ "All available criteria are already in use. Remove one to add it elsewhere."
116
+ )
117
+ self.criteria_exhausted_hint.setObjectName("hint")
118
+ self.criteria_exhausted_hint.setWordWrap(True)
119
+ layout.addWidget(self.criteria_exhausted_hint)
120
+
121
+ preview_title = QLabel("EXAMPLE OUTPUT PATH")
122
+ preview_title.setObjectName("organizationSection")
123
+ layout.addWidget(preview_title)
124
+ preview_hint = QLabel(
125
+ "Folder names and the file name are examples; actual output depends on the font."
126
+ )
127
+ preview_hint.setObjectName("hint")
128
+ preview_hint.setWordWrap(True)
129
+ layout.addWidget(preview_hint)
130
+ self.preview = QLabel()
131
+ self.preview.setWordWrap(True)
132
+ self.preview.setObjectName("pathLabel")
133
+ self.preview.setTextFormat(Qt.TextFormat.RichText)
134
+ self.preview.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
135
+ layout.addWidget(self.preview)
136
+ layout.addStretch()
137
+
138
+ self.buttons = QDialogButtonBox(
139
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
140
+ )
141
+ apply_button = self.buttons.button(QDialogButtonBox.StandardButton.Ok)
142
+ if apply_button is not None:
143
+ apply_button.setText("Apply")
144
+ apply_button.setObjectName("primaryButton")
145
+ self.buttons.accepted.connect(self.accept)
146
+ self.buttons.rejected.connect(self.reject)
147
+ layout.addWidget(self.buttons)
148
+
149
+ for level in levels:
150
+ supported = tuple(key for key in level if key in self._available)
151
+ if supported:
152
+ self._add_level(supported)
153
+ self._refresh()
154
+
155
+ @property
156
+ def levels(self) -> tuple[tuple[str, ...], ...]:
157
+ return tuple(tuple(str(combo.currentData()) for combo in row.combos) for row in self._rows)
158
+
159
+ def _add_level(self, criteria: tuple[str, ...] | None = None, *_: object) -> None:
160
+ focus_new_level = criteria is None and self.add_level_button.hasFocus()
161
+ used = {key for level in self.levels for key in level}
162
+ choices = [key for key in self._available if key not in used]
163
+ if criteria is None:
164
+ if not choices:
165
+ return
166
+ criteria = (choices[0],)
167
+ row_widget = QFrame(self)
168
+ row_widget.setObjectName("organizationLevel")
169
+ row_layout = QVBoxLayout(row_widget)
170
+ row_layout.setContentsMargins(12, 9, 12, 10)
171
+ row_layout.setSpacing(8)
172
+ header_layout = QHBoxLayout()
173
+ number = QLabel()
174
+ number.setObjectName("organizationLevelNumber")
175
+ header_layout.addWidget(number)
176
+ header_layout.addWidget(QLabel("Folder level"))
177
+ header_layout.addStretch()
178
+ move_up = KeyboardPushButton("↑")
179
+ move_up.setObjectName("organizationIconButton")
180
+ move_up.setToolTip("Move this folder level up")
181
+ move_up.setFixedSize(30, 28)
182
+ move_down = KeyboardPushButton("↓")
183
+ move_down.setObjectName("organizationIconButton")
184
+ move_down.setToolTip("Move this folder level down")
185
+ move_down.setFixedSize(30, 28)
186
+ remove_level = KeyboardPushButton("Remove level")
187
+ remove_level.setObjectName("organizationRemoveButton")
188
+ remove_level.setToolTip("Remove this folder level and all its criteria.")
189
+ remove_level.setAccessibleName("Remove folder level and all its criteria")
190
+ header_layout.addWidget(move_up)
191
+ header_layout.addWidget(move_down)
192
+ header_layout.addWidget(remove_level)
193
+ row_layout.addLayout(header_layout)
194
+
195
+ criteria_layout = QGridLayout()
196
+ criteria_layout.setContentsMargins(0, 0, 0, 0)
197
+ criteria_layout.setHorizontalSpacing(8)
198
+ criteria_layout.setVerticalSpacing(6)
199
+ row_layout.addLayout(criteria_layout)
200
+ add_criterion = KeyboardPushButton("+ Add criterion")
201
+ add_criterion.setObjectName("organizationAddCriterionButton")
202
+ row_layout.addWidget(add_criterion, 0, Qt.AlignmentFlag.AlignLeft)
203
+ row = _LevelRow(
204
+ row_widget, number, criteria_layout, add_criterion, move_up, move_down, remove_level
205
+ )
206
+ add_criterion.clicked.connect(lambda: self._add_criterion(row))
207
+ move_up.clicked.connect(lambda: self._move_level(row, -1))
208
+ move_down.clicked.connect(lambda: self._move_level(row, 1))
209
+ remove_level.clicked.connect(lambda: self._remove_level(row))
210
+ for criterion in criteria:
211
+ self._append_criterion(row, criterion)
212
+ self._rows.append(row)
213
+ self._refresh()
214
+ if focus_new_level:
215
+ row.combos[0].setFocus()
216
+
217
+ def _append_criterion(self, row: _LevelRow, criterion: str) -> None:
218
+ combo = QComboBox()
219
+ combo.setMinimumWidth(130)
220
+ model = QStandardItemModel(combo)
221
+ for key in self._available:
222
+ item = QStandardItem(CRITERIA[key])
223
+ item.setData(key, Qt.ItemDataRole.UserRole)
224
+ model.appendRow(item)
225
+ combo.setModel(model)
226
+ combo.setCurrentIndex(combo.findData(criterion))
227
+ combo.currentIndexChanged.connect(
228
+ lambda _index, current=combo: self._criterion_changed(current)
229
+ )
230
+ move_left = KeyboardPushButton("←")
231
+ move_left.setObjectName("organizationIconButton")
232
+ move_left.setFixedSize(30, 34)
233
+ move_left.setToolTip("Move this criterion earlier in the folder name.")
234
+ move_left.setAccessibleName("Move criterion earlier")
235
+ move_left.clicked.connect(lambda: self._move_criterion(row, combo, -1))
236
+ move_right = KeyboardPushButton("→")
237
+ move_right.setObjectName("organizationIconButton")
238
+ move_right.setFixedSize(30, 34)
239
+ move_right.setToolTip("Move this criterion later in the folder name.")
240
+ move_right.setAccessibleName("Move criterion later")
241
+ move_right.clicked.connect(lambda: self._move_criterion(row, combo, 1))
242
+ remove = KeyboardPushButton("×")
243
+ remove.setObjectName("organizationRemoveCriterionButton")
244
+ remove.setFixedSize(30, 34)
245
+ remove.setToolTip("Remove this criterion; removing the last one also removes the level.")
246
+ remove.setAccessibleName("Remove criterion")
247
+ remove.clicked.connect(lambda: self._remove_criterion(row, combo))
248
+ wrapper = QWidget()
249
+ wrapper_layout = QHBoxLayout(wrapper)
250
+ wrapper_layout.setContentsMargins(0, 0, 0, 0)
251
+ wrapper_layout.setSpacing(2)
252
+ wrapper_layout.addWidget(combo)
253
+ wrapper_layout.addWidget(move_left)
254
+ wrapper_layout.addWidget(move_right)
255
+ wrapper_layout.addWidget(remove)
256
+ row.combos.append(combo)
257
+ row.criterion_moves[combo] = (move_left, move_right)
258
+ row.criterion_removes[combo] = remove
259
+ self._layout_criteria(row)
260
+ self._previous[combo] = criterion
261
+ self._models[combo] = model
262
+
263
+ def _add_criterion(self, row: _LevelRow) -> None:
264
+ focus_new_criterion = row.add_criterion.hasFocus()
265
+ used = {key for level in self.levels for key in level}
266
+ criterion = next((key for key in self._available if key not in used), None)
267
+ if criterion is not None:
268
+ self._append_criterion(row, criterion)
269
+ self._refresh()
270
+ if focus_new_criterion:
271
+ row.combos[-1].setFocus()
272
+
273
+ def _criterion_changed(self, combo: QComboBox) -> None:
274
+ selected = str(combo.currentData())
275
+ if any(
276
+ other is not combo and other.currentData() == selected for other in self._all_combos()
277
+ ):
278
+ combo.blockSignals(True)
279
+ combo.setCurrentIndex(combo.findData(self._previous[combo]))
280
+ combo.blockSignals(False)
281
+ return
282
+ self._previous[combo] = selected
283
+ self._refresh()
284
+
285
+ def _remove_criterion(self, row: _LevelRow, combo: QComboBox) -> None:
286
+ wrapper = combo.parentWidget()
287
+ focused = self.focusWidget()
288
+ focus_removed = (
289
+ wrapper is not None
290
+ and focused is not None
291
+ and (focused is wrapper or wrapper.isAncestorOf(focused))
292
+ )
293
+ index = row.combos.index(combo)
294
+ if combo in row.combos:
295
+ row.combos.remove(combo)
296
+ row.criterion_moves.pop(combo, None)
297
+ row.criterion_removes.pop(combo, None)
298
+ if wrapper is not None:
299
+ row.criteria_layout.removeWidget(wrapper)
300
+ wrapper.hide()
301
+ wrapper.deleteLater()
302
+ self._previous.pop(combo, None)
303
+ self._models.pop(combo, None)
304
+ if not row.combos:
305
+ self._remove_level(row)
306
+ else:
307
+ self._layout_criteria(row)
308
+ self._refresh()
309
+ if focus_removed:
310
+ row.combos[min(index, len(row.combos) - 1)].setFocus()
311
+ if focus_removed and not row.combos:
312
+ self.add_level_button.setFocus()
313
+
314
+ def _remove_level(self, row: _LevelRow) -> None:
315
+ focused = self.focusWidget()
316
+ focus_removed = focused is not None and (
317
+ focused is row.widget or row.widget.isAncestorOf(focused)
318
+ )
319
+ index = self._rows.index(row)
320
+ if row in self._rows:
321
+ self._rows.remove(row)
322
+ for combo in row.combos:
323
+ self._previous.pop(combo, None)
324
+ self._models.pop(combo, None)
325
+ self._levels_layout.removeWidget(row.widget)
326
+ row.widget.hide()
327
+ row.widget.deleteLater()
328
+ self._refresh()
329
+ if focus_removed:
330
+ if self._rows:
331
+ self._rows[min(index, len(self._rows) - 1)].combos[0].setFocus()
332
+ else:
333
+ self.add_level_button.setFocus()
334
+
335
+ def _move_level(self, row: _LevelRow, offset: int) -> None:
336
+ focused = self.focusWidget()
337
+ index = self._rows.index(row)
338
+ target = index + offset
339
+ if 0 <= target < len(self._rows):
340
+ self._rows[index], self._rows[target] = self._rows[target], self._rows[index]
341
+ self._refresh()
342
+ if focused in (row.move_up, row.move_down) and not focused.isEnabled():
343
+ row.combos[0].setFocus()
344
+
345
+ def _move_criterion(self, row: _LevelRow, combo: QComboBox, offset: int) -> None:
346
+ focused = self.focusWidget()
347
+ index = row.combos.index(combo)
348
+ target = index + offset
349
+ if 0 <= target < len(row.combos):
350
+ row.combos[index], row.combos[target] = row.combos[target], row.combos[index]
351
+ self._layout_criteria(row)
352
+ self._refresh()
353
+ if focused in row.criterion_moves[combo] and not focused.isEnabled():
354
+ combo.setFocus()
355
+
356
+ def _all_combos(self) -> list[QComboBox]:
357
+ return [combo for row in self._rows for combo in row.combos]
358
+
359
+ @staticmethod
360
+ def _layout_criteria(row: _LevelRow) -> None:
361
+ while row.criteria_layout.count():
362
+ item = row.criteria_layout.takeAt(0)
363
+ if item is not None:
364
+ widget = item.widget()
365
+ if widget is not None:
366
+ widget.setParent(row.widget)
367
+ for index, combo in enumerate(row.combos):
368
+ wrapper = combo.parentWidget()
369
+ if wrapper is not None:
370
+ row.criteria_layout.addWidget(wrapper, index // 2, index % 2)
371
+
372
+ def _refresh(self) -> None:
373
+ used = {key for level in self.levels for key in level}
374
+ has_available_criteria = any(key not in used for key in self._available)
375
+ self.criteria_exhausted_hint.setVisible(not has_available_criteria)
376
+ self.add_level_button.setEnabled(has_available_criteria)
377
+ self.empty_hint.setVisible(not self._rows)
378
+ for index, row in enumerate(self._rows):
379
+ row.number.setText(f"{index + 1}.")
380
+ self._levels_layout.removeWidget(row.widget)
381
+ self._levels_layout.insertWidget(index, row.widget)
382
+ row.move_up.setEnabled(index > 0)
383
+ row.move_down.setEnabled(index < len(self._rows) - 1)
384
+ row.add_criterion.setEnabled(has_available_criteria)
385
+ for position, combo in enumerate(row.combos):
386
+ move_left, move_right = row.criterion_moves[combo]
387
+ move_left.setEnabled(position > 0)
388
+ move_right.setEnabled(position < len(row.combos) - 1)
389
+ used_by_others = used - {str(combo.currentData())}
390
+ model = self._models[combo]
391
+ for item_index, key in enumerate(self._available):
392
+ item = model.item(item_index)
393
+ if item is not None:
394
+ item.setEnabled(key not in used_by_others)
395
+ pieces = [organization_level_name(level, self._values) for level in self.levels]
396
+ suffix = "/" if pieces else ""
397
+ folder_path = escape(f"{'/'.join(pieces)}{suffix}")
398
+ filename = escape(f"font{self._extension}")
399
+ self.preview.setText(f"{folder_path}<b>{filename}</b>")
400
+ self._configure_tab_order()
401
+
402
+ def _configure_tab_order(self) -> None:
403
+ chain: list[QWidget] = []
404
+ for row in self._rows:
405
+ chain.extend((row.move_up, row.move_down, row.remove_level))
406
+ for combo in row.combos:
407
+ chain.append(combo)
408
+ chain.extend(row.criterion_moves[combo])
409
+ chain.append(row.criterion_removes[combo])
410
+ chain.append(row.add_criterion)
411
+ chain.append(self.add_level_button)
412
+ for role in (QDialogButtonBox.StandardButton.Ok, QDialogButtonBox.StandardButton.Cancel):
413
+ button = self.buttons.button(role)
414
+ if button is not None:
415
+ chain.append(button)
416
+ for current, following in zip(chain, chain[1:], strict=False):
417
+ QWidget.setTabOrder(current, following)