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,583 @@
1
+ """Per-font tree of the static instances to export from variable fonts."""
2
+
3
+ from pathlib import Path
4
+
5
+ from PySide6.QtCore import QPoint, Qt, Signal
6
+ from PySide6.QtGui import (
7
+ QColor,
8
+ QDragEnterEvent,
9
+ QDragLeaveEvent,
10
+ QDragMoveEvent,
11
+ QDropEvent,
12
+ QKeySequence,
13
+ QShortcut,
14
+ )
15
+ from PySide6.QtWidgets import (
16
+ QDialog,
17
+ QDialogButtonBox,
18
+ QGridLayout,
19
+ QHeaderView,
20
+ QLabel,
21
+ QMenu,
22
+ QMessageBox,
23
+ QTreeWidget,
24
+ QTreeWidgetItem,
25
+ QVBoxLayout,
26
+ QWidget,
27
+ )
28
+
29
+ from font_converter.gui.styles import DARK, LIGHT
30
+ from font_converter.gui.widgets import (
31
+ DecimalSpinBox,
32
+ StepperField,
33
+ accept_local_files,
34
+ dropped_paths,
35
+ set_drag_over,
36
+ )
37
+ from font_converter.inspection import AxisInfo, FontInfo
38
+ from font_converter.service import InstanceSpec, instance_label, instance_stem
39
+
40
+ _SPEC_ROLE = Qt.ItemDataRole.UserRole
41
+ _CUSTOM_ROLE = Qt.ItemDataRole.UserRole + 1
42
+ _PATH_ROLE = Qt.ItemDataRole.UserRole + 2
43
+ _AXES_ROLE = Qt.ItemDataRole.UserRole + 3
44
+ _OUTLINES_ROLE = Qt.ItemDataRole.UserRole + 4
45
+ _LOCATION_COLUMN = 4
46
+
47
+ _Memory = tuple[set[InstanceSpec], list[InstanceSpec]]
48
+
49
+
50
+ def _number(value: float) -> str:
51
+ return f"{value:g}"
52
+
53
+
54
+ def location_text(coordinates: tuple[tuple[str, float], ...]) -> str:
55
+ if not coordinates:
56
+ return "Default"
57
+ return ", ".join(f"{tag}={_number(value)}" for tag, value in coordinates)
58
+
59
+
60
+ class CustomInstanceDialog(QDialog):
61
+ """Ask for one value per axis of a single variable font."""
62
+
63
+ def __init__(
64
+ self,
65
+ axes: tuple[AxisInfo, ...],
66
+ named_instances: tuple[InstanceSpec, ...],
67
+ font_name: str,
68
+ input_path: Path,
69
+ parent: QWidget,
70
+ coordinates: tuple[tuple[str, float], ...] | None = None,
71
+ ) -> None:
72
+ super().__init__(parent)
73
+ self.setWindowTitle(
74
+ "Edit custom instance" if coordinates is not None else "Custom instance"
75
+ )
76
+ self._axes = axes
77
+ self._named_instances = named_instances
78
+ self._input_path = input_path
79
+ layout = QVBoxLayout(self)
80
+ layout.setContentsMargins(16, 12, 16, 14)
81
+ layout.setSpacing(9)
82
+ intro = QLabel(f"Axis values for {font_name}")
83
+ intro.setObjectName("hint")
84
+ intro.setWordWrap(True)
85
+ layout.addWidget(intro)
86
+
87
+ grid = QGridLayout()
88
+ grid.setHorizontalSpacing(12)
89
+ grid.setVerticalSpacing(12)
90
+ self._boxes: dict[str, DecimalSpinBox] = {}
91
+ for row, axis in enumerate(axes):
92
+ box = DecimalSpinBox()
93
+ box.setDecimals(2)
94
+ box.setRange(axis.minimum, axis.maximum)
95
+ box.setValue(dict(coordinates or ()).get(axis.tag, axis.default))
96
+ self._boxes[axis.tag] = box
97
+ field = StepperField(box)
98
+ field.setFixedWidth(170)
99
+ range_label = QLabel(f"{_number(axis.minimum)}–{_number(axis.maximum)}")
100
+ range_label.setObjectName("hint")
101
+ grid.addWidget(QLabel(axis.tag), row, 0)
102
+ grid.addWidget(field, row, 1)
103
+ grid.addWidget(range_label, row, 2)
104
+ layout.addLayout(grid)
105
+
106
+ self.filename_preview = QLabel()
107
+ self.filename_preview.setObjectName("hint")
108
+ self.filename_preview.setWordWrap(True)
109
+ layout.addWidget(self.filename_preview)
110
+ for box in self._boxes.values():
111
+ box.valueChanged.connect(self._update_filename_preview)
112
+ self._update_filename_preview()
113
+
114
+ buttons = QDialogButtonBox(
115
+ QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
116
+ )
117
+ buttons.accepted.connect(self.accept)
118
+ buttons.rejected.connect(self.reject)
119
+ layout.addWidget(buttons)
120
+
121
+ def coordinates(self) -> tuple[tuple[str, float], ...]:
122
+ return tuple((tag, box.value()) for tag, box in self._boxes.items())
123
+
124
+ def _update_filename_preview(self) -> None:
125
+ coordinates = self.coordinates()
126
+ label = instance_label(coordinates)
127
+ coordinates_by_tag = dict(coordinates)
128
+ named_instance = next(
129
+ (
130
+ spec
131
+ for spec in self._named_instances
132
+ if spec.name
133
+ and len(spec.coordinates) == len(coordinates_by_tag)
134
+ and all(
135
+ tag in coordinates_by_tag and abs(coordinates_by_tag[tag] - value) < 0.0000005
136
+ for tag, value in spec.coordinates
137
+ )
138
+ ),
139
+ None,
140
+ )
141
+ stem = instance_stem(
142
+ self._input_path,
143
+ named_instance.name if named_instance is not None else None,
144
+ label,
145
+ )
146
+ self.filename_preview.setText(f"Output file: {stem}{self._input_path.suffix}")
147
+
148
+
149
+ class InstanceTree(QTreeWidget):
150
+ """Variable fonts as parents, their exportable instances as checkable children."""
151
+
152
+ paths_dropped = Signal(list)
153
+ checksChanged = Signal()
154
+ actionsChanged = Signal()
155
+
156
+ def __init__(self) -> None:
157
+ super().__init__()
158
+ self.setObjectName("instanceTree")
159
+ self.setAcceptDrops(True)
160
+ self.setProperty("dragOver", "false")
161
+ self.setColumnCount(5)
162
+ self.setHeaderLabels(["Font / instance", "Format", "Outlines", "UPM", "Location"])
163
+ self.headerItem().setToolTip(
164
+ _LOCATION_COLUMN, "Axis coordinates. Drag the column divider to resize."
165
+ )
166
+ self.setRootIsDecorated(True)
167
+ self.setAlternatingRowColors(True)
168
+ self.setUniformRowHeights(True)
169
+ self.setTabKeyNavigation(False)
170
+ self.setSelectionMode(QTreeWidget.SelectionMode.SingleSelection)
171
+ self.setSortingEnabled(True)
172
+ self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
173
+ header = self.header()
174
+ header.setStretchLastSection(False)
175
+ header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
176
+ for column in range(1, self.columnCount()):
177
+ header.setSectionResizeMode(column, QHeaderView.ResizeMode.Interactive)
178
+ header.resizeSection(1, 90)
179
+ header.resizeSection(2, 90)
180
+ header.resizeSection(3, 70)
181
+ header.resizeSection(_LOCATION_COLUMN, 220)
182
+ self.sortByColumn(0, Qt.SortOrder.AscendingOrder)
183
+ self._filter_text = ""
184
+ self._hide_incompatible = False
185
+ self._memory: dict[Path, _Memory] = {}
186
+ self._muted_color = QColor(LIGHT.text_disabled)
187
+ self.itemChanged.connect(self._on_item_changed)
188
+ self.currentItemChanged.connect(lambda *_: self.actionsChanged.emit())
189
+ self.customContextMenuRequested.connect(self._show_menu)
190
+ self.itemDoubleClicked.connect(self._on_item_double_clicked)
191
+ self._delete_shortcut = QShortcut(QKeySequence.StandardKey.Delete, self)
192
+ self._delete_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
193
+ self._delete_shortcut.activated.connect(self.remove_custom)
194
+ self._menu_shortcut = QShortcut(QKeySequence("Shift+F10"), self)
195
+ self._menu_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut)
196
+ self._menu_shortcut.activated.connect(self._show_current_menu)
197
+
198
+ def set_dark_theme(self, enabled: bool) -> None:
199
+ palette = DARK if enabled else LIGHT
200
+ self._muted_color = QColor(palette.text_disabled)
201
+ for index in range(self.topLevelItemCount()):
202
+ item = self.topLevelItem(index)
203
+ if item is not None and item.data(0, _PATH_ROLE) is None:
204
+ for column in range(self.columnCount()):
205
+ item.setForeground(column, self._muted_color)
206
+ self.viewport().update()
207
+
208
+ def add_skipped(self, path: Path, label: str, reason: str, info: FontInfo) -> None:
209
+ """List a font that cannot be used, greyed out and with the reason."""
210
+ item = QTreeWidgetItem(self._row_texts(label, info, reason))
211
+ item.setFlags(Qt.ItemFlag.ItemIsEnabled)
212
+ item.setToolTip(0, f"{path}\n{reason}")
213
+ item.setToolTip(_LOCATION_COLUMN, reason)
214
+ for column in range(self.columnCount()):
215
+ item.setForeground(column, self._muted_color)
216
+ self.addTopLevelItem(item)
217
+ self.set_filter(self._filter_text)
218
+ self.viewport().update()
219
+ self.checksChanged.emit()
220
+
221
+ def add_font(
222
+ self, path: Path, label: str, info: FontInfo, checked_by_default: bool = True
223
+ ) -> None:
224
+ remembered = self._memory.get(path)
225
+ checked, custom = remembered if remembered else (None, [])
226
+ was_blocked = self.blockSignals(True)
227
+ parent = QTreeWidgetItem(self._row_texts(label, info, ""))
228
+ parent.setFlags(
229
+ Qt.ItemFlag.ItemIsEnabled
230
+ | Qt.ItemFlag.ItemIsSelectable
231
+ | Qt.ItemFlag.ItemIsUserCheckable
232
+ | Qt.ItemFlag.ItemIsAutoTristate
233
+ )
234
+ parent.setToolTip(0, str(path))
235
+ parent.setData(0, _PATH_ROLE, path)
236
+ parent.setData(0, _AXES_ROLE, info.axes)
237
+ parent.setData(0, _OUTLINES_ROLE, info.outlines)
238
+ self.addTopLevelItem(parent)
239
+ named = [InstanceSpec(i.coordinates, i.name) for i in info.instances]
240
+ for spec in named or [InstanceSpec(())]:
241
+ self._add_child(
242
+ parent,
243
+ spec,
244
+ custom=False,
245
+ checked=checked_by_default if checked is None else spec in checked,
246
+ )
247
+ for spec in custom:
248
+ self._add_child(parent, spec, custom=True, checked=checked is None or spec in checked)
249
+ self._update_summary(parent)
250
+ self._fit_location_column()
251
+ self.blockSignals(was_blocked)
252
+ self.set_filter(self._filter_text)
253
+ self.viewport().update()
254
+ self.checksChanged.emit()
255
+
256
+ def set_font_checked(self, path: Path, checked: bool) -> None:
257
+ font = next((item for item in self._fonts() if item.data(0, _PATH_ROLE) == path), None)
258
+ if font is None:
259
+ return
260
+ was_blocked = self.blockSignals(True)
261
+ state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
262
+ for child in self._children(font):
263
+ child.setCheckState(0, state)
264
+ self._update_summary(font)
265
+ self._remember(font)
266
+ self.blockSignals(was_blocked)
267
+ self.checksChanged.emit()
268
+
269
+ @staticmethod
270
+ def _row_texts(label: str, info: FontInfo, location: str) -> list[str]:
271
+ upm = "Mixed" if info.upm is None else str(info.upm)
272
+ return [label, info.format, info.outlines, upm, location]
273
+
274
+ def _add_child(
275
+ self, parent: QTreeWidgetItem, spec: InstanceSpec, custom: bool, checked: bool
276
+ ) -> QTreeWidgetItem:
277
+ name = spec.name or ("Default instance" if not spec.coordinates else "Custom instance")
278
+ location = location_text(spec.coordinates)
279
+ child = QTreeWidgetItem([name, "", "", "", location])
280
+ child.setFlags(
281
+ Qt.ItemFlag.ItemIsEnabled
282
+ | Qt.ItemFlag.ItemIsSelectable
283
+ | Qt.ItemFlag.ItemIsUserCheckable
284
+ )
285
+ child.setCheckState(0, Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
286
+ child.setData(0, _SPEC_ROLE, spec)
287
+ child.setData(0, _CUSTOM_ROLE, custom)
288
+ child.setToolTip(_LOCATION_COLUMN, location)
289
+ parent.addChild(child)
290
+ return child
291
+
292
+ def _fit_location_column(self) -> None:
293
+ self.resizeColumnToContents(_LOCATION_COLUMN)
294
+ self.header().resizeSection(
295
+ _LOCATION_COLUMN, min(max(self.columnWidth(_LOCATION_COLUMN), 180), 360)
296
+ )
297
+
298
+ def set_hide_incompatible(self, hide: bool) -> None:
299
+ """Show or hide fonts that cannot be exported as variable instances."""
300
+ self._hide_incompatible = hide
301
+ self.set_filter(self._filter_text)
302
+
303
+ def set_filter(self, text: str) -> None:
304
+ """Filter fonts and instances while keeping a matching font's children visible."""
305
+ self._filter_text = text.casefold().strip()
306
+ for font in self._fonts():
307
+ font_match = self._matches(font)
308
+ child_match = False
309
+ for child in self._children(font):
310
+ matches = not self._filter_text or font_match or self._matches(child)
311
+ child.setHidden(not matches)
312
+ child_match = child_match or matches
313
+ font.setHidden(bool(self._filter_text) and not font_match and not child_match)
314
+ for index in range(self.topLevelItemCount()):
315
+ item = self.topLevelItem(index)
316
+ if item is not None and item.data(0, _PATH_ROLE) is None:
317
+ item.setHidden(
318
+ self._hide_incompatible or (bool(self._filter_text) and not self._matches(item))
319
+ )
320
+
321
+ def _matches(self, item: QTreeWidgetItem) -> bool:
322
+ if not self._filter_text:
323
+ return True
324
+ text = " ".join(
325
+ item.text(column) + " " + item.toolTip(column) for column in range(self.columnCount())
326
+ )
327
+ return self._filter_text in text.casefold()
328
+
329
+ def _update_summary(self, parent: QTreeWidgetItem) -> None:
330
+ total = parent.childCount()
331
+ selected = sum(c.checkState(0) == Qt.CheckState.Checked for c in self._children(parent))
332
+ parent.setText(_LOCATION_COLUMN, f"{selected} of {total} selected")
333
+ parent.setToolTip(_LOCATION_COLUMN, f"{selected} of {total} instances selected")
334
+
335
+ def _on_item_changed(self, item: QTreeWidgetItem, column: int) -> None:
336
+ if column != 0:
337
+ return
338
+ parent = item.parent() or item
339
+ was_blocked = self.blockSignals(True)
340
+ self._update_summary(parent)
341
+ self.blockSignals(was_blocked)
342
+ self._remember(parent)
343
+ self.checksChanged.emit()
344
+
345
+ def _fonts(self) -> list[QTreeWidgetItem]:
346
+ items = (self.topLevelItem(i) for i in range(self.topLevelItemCount()))
347
+ return [item for item in items if item and item.data(0, _PATH_ROLE) is not None]
348
+
349
+ @staticmethod
350
+ def _children(font: QTreeWidgetItem) -> list[QTreeWidgetItem]:
351
+ return [item for i in range(font.childCount()) if (item := font.child(i))]
352
+
353
+ def _remember(self, font: QTreeWidgetItem) -> None:
354
+ children = self._children(font)
355
+ self._memory[font.data(0, _PATH_ROLE)] = (
356
+ {c.data(0, _SPEC_ROLE) for c in children if c.checkState(0) == Qt.CheckState.Checked},
357
+ [c.data(0, _SPEC_ROLE) for c in children if c.data(0, _CUSTOM_ROLE)],
358
+ )
359
+
360
+ def clear_fonts(self) -> None:
361
+ """Drop the rows for a rescan while keeping the user's choices."""
362
+ was_blocked = self.blockSignals(True)
363
+ self.clear()
364
+ self.blockSignals(was_blocked)
365
+ self.viewport().update()
366
+ self.checksChanged.emit()
367
+
368
+ def forget(self) -> None:
369
+ self._memory.clear()
370
+
371
+ def selection(self) -> tuple[tuple[Path, tuple[InstanceSpec, ...]], ...]:
372
+ result = []
373
+ for font in self._fonts():
374
+ specs = tuple(
375
+ c.data(0, _SPEC_ROLE)
376
+ for c in self._children(font)
377
+ if c.checkState(0) == Qt.CheckState.Checked
378
+ )
379
+ if specs:
380
+ result.append((font.data(0, _PATH_ROLE), specs))
381
+ return tuple(result)
382
+
383
+ def selected_paths(self) -> list[Path]:
384
+ return [path for path, _ in self.selection()]
385
+
386
+ def instance_count(self) -> int:
387
+ return sum(len(specs) for _, specs in self.selection())
388
+
389
+ def font_count(self) -> int:
390
+ return len(self._fonts())
391
+
392
+ def skipped_count(self) -> int:
393
+ return self.topLevelItemCount() - len(self._fonts())
394
+
395
+ def selected_outlines(self) -> set[str]:
396
+ """Outline kinds ("TrueType"/"PostScript") of the fonts with selected instances."""
397
+ return {
398
+ font.data(0, _OUTLINES_ROLE)
399
+ for font in self._fonts()
400
+ if any(c.checkState(0) == Qt.CheckState.Checked for c in self._children(font))
401
+ }
402
+
403
+ def auto_expand(self) -> None:
404
+ """Show the instances right away when there is a single variable font."""
405
+ fonts = self._fonts()
406
+ if len(fonts) == 1:
407
+ fonts[0].setExpanded(True)
408
+
409
+ def check_state(self) -> Qt.CheckState | None:
410
+ states = {c.checkState(0) for font in self._fonts() for c in self._children(font)}
411
+ if not states:
412
+ return None
413
+ if len(states) > 1:
414
+ return Qt.CheckState.PartiallyChecked
415
+ return states.pop()
416
+
417
+ def set_all_checked(self, checked: bool) -> None:
418
+ state = Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked
419
+ was_blocked = self.blockSignals(True)
420
+ for font in self._fonts():
421
+ for child in self._children(font):
422
+ child.setCheckState(0, state)
423
+ self._update_summary(font)
424
+ self._remember(font)
425
+ self.blockSignals(was_blocked)
426
+ self.checksChanged.emit()
427
+
428
+ def _current_font(self) -> QTreeWidgetItem | None:
429
+ item = self.currentItem()
430
+ if item is None:
431
+ return None
432
+ font = item.parent() or item
433
+ return font if font.data(0, _PATH_ROLE) is not None else None
434
+
435
+ def can_add(self) -> bool:
436
+ return self._current_font() is not None
437
+
438
+ def can_remove(self) -> bool:
439
+ item = self.currentItem()
440
+ return item is not None and bool(item.data(0, _CUSTOM_ROLE))
441
+
442
+ def add_custom(self) -> None:
443
+ self._edit_or_add_custom()
444
+
445
+ def edit_custom(self) -> None:
446
+ item = self.currentItem()
447
+ if item is not None and item.data(0, _CUSTOM_ROLE):
448
+ self._edit_or_add_custom(item)
449
+
450
+ def _edit_or_add_custom(self, editing: QTreeWidgetItem | None = None) -> None:
451
+ font = self._current_font()
452
+ if font is None:
453
+ return
454
+ axes: tuple[AxisInfo, ...] = font.data(0, _AXES_ROLE)
455
+ if not axes:
456
+ return
457
+ named_instances = tuple(
458
+ child.data(0, _SPEC_ROLE)
459
+ for child in self._children(font)
460
+ if not child.data(0, _CUSTOM_ROLE) and child.data(0, _SPEC_ROLE).name is not None
461
+ )
462
+ input_path: Path = font.data(0, _PATH_ROLE)
463
+ dialog = CustomInstanceDialog(
464
+ axes,
465
+ named_instances,
466
+ font.text(0),
467
+ input_path,
468
+ self.window(),
469
+ editing.data(0, _SPEC_ROLE).coordinates if editing is not None else None,
470
+ )
471
+ if dialog.exec() != QDialog.DialogCode.Accepted:
472
+ return
473
+ spec = InstanceSpec(dialog.coordinates())
474
+ existing = next(
475
+ (
476
+ c
477
+ for c in self._children(font)
478
+ if c is not editing and c.data(0, _SPEC_ROLE).coordinates == spec.coordinates
479
+ ),
480
+ None,
481
+ )
482
+ if existing is not None and editing is not None:
483
+ QMessageBox.information(
484
+ self.window(),
485
+ "Instance already exists",
486
+ f'These values match the existing instance "{existing.text(0)}".',
487
+ )
488
+ return
489
+ was_blocked = self.blockSignals(True)
490
+ if existing is not None:
491
+ existing.setCheckState(0, Qt.CheckState.Checked)
492
+ target = existing
493
+ existing_spec: InstanceSpec = existing.data(0, _SPEC_ROLE)
494
+ if existing_spec.name is not None:
495
+ QMessageBox.information(
496
+ self.window(),
497
+ "Named instance already exists",
498
+ f'These values match the named instance "{existing.text(0)}". '
499
+ "The existing instance has been selected.",
500
+ )
501
+ elif editing is not None:
502
+ was_checked = editing.checkState(0) == Qt.CheckState.Checked
503
+ editing.setText(0, "Custom instance")
504
+ editing.setText(_LOCATION_COLUMN, location_text(spec.coordinates))
505
+ editing.setToolTip(_LOCATION_COLUMN, location_text(spec.coordinates))
506
+ editing.setData(0, _SPEC_ROLE, spec)
507
+ editing.setCheckState(
508
+ 0, Qt.CheckState.Checked if was_checked else Qt.CheckState.Unchecked
509
+ )
510
+ target = editing
511
+ else:
512
+ target = self._add_child(font, spec, custom=True, checked=True)
513
+ font.setExpanded(True)
514
+ self._update_summary(font)
515
+ self.blockSignals(was_blocked)
516
+ self._remember(font)
517
+ self.setCurrentItem(target)
518
+ self.set_filter(self._filter_text)
519
+ self.checksChanged.emit()
520
+
521
+ def _on_item_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None:
522
+ if item.data(0, _CUSTOM_ROLE):
523
+ self.setCurrentItem(item)
524
+ self.edit_custom()
525
+
526
+ def remove_custom(self) -> None:
527
+ item = self.currentItem()
528
+ parent = item.parent() if item is not None else None
529
+ if item is None or parent is None or not item.data(0, _CUSTOM_ROLE):
530
+ return
531
+ was_blocked = self.blockSignals(True)
532
+ parent.removeChild(item)
533
+ self._update_summary(parent)
534
+ self.blockSignals(was_blocked)
535
+ self._remember(parent)
536
+ self.actionsChanged.emit()
537
+ self.checksChanged.emit()
538
+
539
+ def _show_menu(self, position: QPoint) -> None:
540
+ item = self.itemAt(position)
541
+ if item is None:
542
+ return
543
+ self.setCurrentItem(item)
544
+ menu = QMenu(self)
545
+ add = menu.addAction("Add custom instance…")
546
+ add.setEnabled(self.can_add())
547
+ edit = menu.addAction("Edit custom instance…")
548
+ edit.setEnabled(self.can_remove())
549
+ remove = menu.addAction("Remove instance")
550
+ remove.setEnabled(self.can_remove())
551
+ chosen = menu.exec(self.viewport().mapToGlobal(position))
552
+ if chosen is add:
553
+ self.add_custom()
554
+ elif chosen is edit:
555
+ self.edit_custom()
556
+ elif chosen is remove:
557
+ self.remove_custom()
558
+
559
+ def _show_current_menu(self) -> None:
560
+ item = self.currentItem()
561
+ if item is not None:
562
+ self.scrollToItem(item)
563
+ self._show_menu(self.visualItemRect(item).center())
564
+
565
+ def dragEnterEvent(self, event: QDragEnterEvent) -> None:
566
+ accept_local_files(event)
567
+ set_drag_over(self, event.isAccepted())
568
+
569
+ def dragMoveEvent(self, event: QDragMoveEvent) -> None:
570
+ accept_local_files(event)
571
+
572
+ def dragLeaveEvent(self, event: QDragLeaveEvent) -> None:
573
+ set_drag_over(self, False)
574
+ event.accept()
575
+
576
+ def dropEvent(self, event: QDropEvent) -> None:
577
+ set_drag_over(self, False)
578
+ paths = dropped_paths(event)
579
+ if paths:
580
+ self.paths_dropped.emit(paths)
581
+ event.acceptProposedAction()
582
+ else:
583
+ event.ignore()
@@ -0,0 +1,51 @@
1
+ """Structured conversion log records kept in memory by the GUI."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Literal
7
+
8
+ LogLevel = Literal["INFO", "WARNING", "ERROR"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class LogEntry:
13
+ timestamp: str
14
+ level: LogLevel
15
+ event: str
16
+ message: str
17
+ context: dict[str, object] = field(default_factory=dict)
18
+
19
+ @classmethod
20
+ def create(
21
+ cls,
22
+ level: LogLevel,
23
+ event: str,
24
+ message: str,
25
+ context: dict[str, object] | None = None,
26
+ ) -> "LogEntry":
27
+ return cls(
28
+ datetime.now().astimezone().isoformat(timespec="milliseconds"),
29
+ level,
30
+ event,
31
+ message,
32
+ context or {},
33
+ )
34
+
35
+ def display_text(self) -> str:
36
+ input_name = self.context.get("input")
37
+ input_label = f" [{input_name}]" if isinstance(input_name, str) else ""
38
+ return f"{self.timestamp} {self.level} [{self.event}]{input_label} {self.message}"
39
+
40
+ def to_json(self) -> str:
41
+ return json.dumps(
42
+ {
43
+ "timestamp": self.timestamp,
44
+ "level": self.level,
45
+ "event": self.event,
46
+ "message": self.message,
47
+ "context": self.context,
48
+ },
49
+ ensure_ascii=False,
50
+ sort_keys=True,
51
+ )