Initial release: Comfy-Studio v1.0.0
Десктопная галерея и менеджер изображений ComfyUI (PyQt6 + SQLite): - мультипапочный мониторинг (watchdog), грид миниатюр, drag-and-drop - теги, избранное, рейтинг, поиск по промтам - парсер метаданных ComfyUI (PNG/WebP/JPEG), редактор workflow/prompt JSON - перевод выделенного текста через контекстное меню (настраиваемые языки) - отправка промта в очередь ComfyUI с внедрением правок и нового seed - полноэкранный просмотр, локализация ru/en, тёмная тема Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Центральная панель: грид миниатюр с асинхронной загрузкой,
|
||||
drag-and-drop наружу, зум, значки workflow/избранного.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
|
||||
QListView, QStyledItemDelegate, QStyle
|
||||
)
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QImage, QPixmap, QFontMetrics, QDrag
|
||||
from PyQt6.QtCore import (
|
||||
Qt, QAbstractListModel, QModelIndex, QSize, QRect,
|
||||
QRunnable, QObject, pyqtSignal, QThreadPool, QMimeData, QUrl
|
||||
)
|
||||
|
||||
from comfy_studio.core.config import MIN_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE, DEFAULT_THUMBNAIL_SIZE
|
||||
from comfy_studio.core.i18n import tr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FileIdRole = Qt.ItemDataRole.UserRole + 1
|
||||
FilePathRole = Qt.ItemDataRole.UserRole + 2
|
||||
HasWorkflowRole = Qt.ItemDataRole.UserRole + 3
|
||||
RatingRole = Qt.ItemDataRole.UserRole + 4
|
||||
PixmapRole = Qt.ItemDataRole.UserRole + 5
|
||||
FavoriteRole = Qt.ItemDataRole.UserRole + 6
|
||||
|
||||
|
||||
class ThumbnailSignals(QObject):
|
||||
loaded = pyqtSignal(str, QImage)
|
||||
|
||||
|
||||
class ThumbnailRunnable(QRunnable):
|
||||
def __init__(self, filepath: str, target_size: int, signals: ThumbnailSignals):
|
||||
super().__init__()
|
||||
self.filepath = filepath
|
||||
self.target_size = target_size
|
||||
self.signals = signals
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
image = QImage(self.filepath)
|
||||
if not image.isNull():
|
||||
scaled = image.scaled(
|
||||
self.target_size, self.target_size,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.signals.loaded.emit(self.filepath, scaled)
|
||||
except Exception as e:
|
||||
logger.debug(f"Ошибка загрузки миниатюры {self.filepath}: {e}")
|
||||
|
||||
|
||||
class ImageModel(QAbstractListModel):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.files: List[Dict[str, Any]] = []
|
||||
self.pixmap_cache: Dict[str, QPixmap] = {}
|
||||
self.loading_paths = set()
|
||||
self.thread_pool = QThreadPool.globalInstance()
|
||||
self.signals = ThumbnailSignals()
|
||||
self.signals.loaded.connect(self._on_thumbnail_loaded)
|
||||
self.thumbnail_size = DEFAULT_THUMBNAIL_SIZE
|
||||
|
||||
def set_files(self, files: List[Dict[str, Any]]):
|
||||
self.beginResetModel()
|
||||
self.files = files
|
||||
self.pixmap_cache.clear()
|
||||
self.loading_paths.clear()
|
||||
self.endResetModel()
|
||||
|
||||
def set_thumbnail_size(self, size: int):
|
||||
self.beginResetModel()
|
||||
self.thumbnail_size = size
|
||||
self.pixmap_cache.clear()
|
||||
self.loading_paths.clear()
|
||||
self.endResetModel()
|
||||
|
||||
def rowCount(self, parent=QModelIndex()) -> int:
|
||||
return len(self.files)
|
||||
|
||||
def supportedDragActions(self) -> Qt.DropAction:
|
||||
return Qt.DropAction.CopyAction
|
||||
|
||||
def mimeTypes(self) -> List[str]:
|
||||
return ["text/uri-list"]
|
||||
|
||||
def mimeData(self, indexes: List[QModelIndex]) -> QMimeData:
|
||||
"""Преобразует выделенные файлы в UriList для ОС и браузеров."""
|
||||
mime_data = QMimeData()
|
||||
urls = []
|
||||
for index in indexes:
|
||||
if index.isValid():
|
||||
filepath = self.data(index, FilePathRole)
|
||||
if filepath and os.path.exists(filepath):
|
||||
urls.append(QUrl.fromLocalFile(filepath))
|
||||
mime_data.setUrls(urls)
|
||||
return mime_data
|
||||
|
||||
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
|
||||
if not index.isValid() or index.row() >= len(self.files):
|
||||
return None
|
||||
file_data = self.files[index.row()]
|
||||
filepath = file_data["filepath"]
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
return file_data["filename"]
|
||||
elif role == FileIdRole:
|
||||
return file_data["id"]
|
||||
elif role == FilePathRole:
|
||||
return filepath
|
||||
elif role == HasWorkflowRole:
|
||||
return bool(file_data.get("has_workflow"))
|
||||
elif role == RatingRole:
|
||||
return file_data.get("rating", 0)
|
||||
elif role == FavoriteRole:
|
||||
return bool(file_data.get("favorite"))
|
||||
elif role == PixmapRole:
|
||||
if filepath in self.pixmap_cache:
|
||||
return self.pixmap_cache[filepath]
|
||||
if filepath not in self.loading_paths:
|
||||
self.loading_paths.add(filepath)
|
||||
runnable = ThumbnailRunnable(filepath, self.thumbnail_size, self.signals)
|
||||
self.thread_pool.start(runnable)
|
||||
return None
|
||||
return None
|
||||
|
||||
def _on_thumbnail_loaded(self, filepath: str, qimage: QImage):
|
||||
self.loading_paths.discard(filepath)
|
||||
pixmap = QPixmap.fromImage(qimage)
|
||||
self.pixmap_cache[filepath] = pixmap
|
||||
for row, f in enumerate(self.files):
|
||||
if f["filepath"] == filepath:
|
||||
idx = self.index(row)
|
||||
self.dataChanged.emit(idx, idx, [PixmapRole])
|
||||
break
|
||||
|
||||
|
||||
class ImageDelegate(QStyledItemDelegate):
|
||||
def __init__(self, model: ImageModel, parent=None):
|
||||
super().__init__(parent)
|
||||
self.model = model
|
||||
|
||||
def paint(self, painter: QPainter, option, index: QModelIndex):
|
||||
painter.save()
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
rect = option.rect
|
||||
selected = option.state & QStyle.StateFlag.State_Selected
|
||||
hovered = option.state & QStyle.StateFlag.State_MouseOver
|
||||
|
||||
bg_color = QColor("#1c1c1c") if hovered else QColor("#141414")
|
||||
if selected:
|
||||
bg_color = QColor("#0d47a1")
|
||||
painter.setBrush(QBrush(bg_color))
|
||||
painter.setPen(QPen(QColor("#1976d2") if selected else QColor("#2d2d2d"), 1.5))
|
||||
painter.drawRoundedRect(rect.adjusted(2, 2, -2, -2), 6, 6)
|
||||
|
||||
cell_size = self.model.thumbnail_size
|
||||
img_rect = QRect(rect.left() + 8, rect.top() + 8, cell_size - 16, cell_size - 16)
|
||||
|
||||
pixmap = index.data(PixmapRole)
|
||||
if pixmap and not pixmap.isNull():
|
||||
w, h = pixmap.width(), pixmap.height()
|
||||
target_w, target_h = img_rect.width(), img_rect.height()
|
||||
if w > 0 and h > 0:
|
||||
ratio = min(target_w / w, target_h / h)
|
||||
final_w, final_h = int(w * ratio), int(h * ratio)
|
||||
x_offset = img_rect.left() + (target_w - final_w) // 2
|
||||
y_offset = img_rect.top() + (target_h - final_h) // 2
|
||||
painter.drawPixmap(x_offset, y_offset, final_w, final_h, pixmap)
|
||||
else:
|
||||
painter.setBrush(QBrush(QColor("#0a0a0a")))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawRoundedRect(img_rect, 4, 4)
|
||||
|
||||
# Имя файла
|
||||
text = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
if text:
|
||||
font = painter.font()
|
||||
font.setPointSize(8)
|
||||
painter.setFont(font)
|
||||
fm = QFontMetrics(font)
|
||||
text_rect = QRect(rect.left() + 4, rect.bottom() - 20, rect.width() - 8, 16)
|
||||
elided_text = fm.elidedText(text, Qt.TextElideMode.ElideMiddle, text_rect.width())
|
||||
painter.setPen(QPen(QColor("#ffffff") if selected else QColor("#8e8e8e")))
|
||||
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_text)
|
||||
|
||||
# Значок наличия workflow (зелёный кружок "W")
|
||||
if index.data(HasWorkflowRole):
|
||||
badge_rect = QRect(rect.right() - 22, rect.top() + 8, 14, 14)
|
||||
painter.setBrush(QBrush(QColor("#2ea44f")))
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
painter.drawEllipse(badge_rect)
|
||||
painter.setPen(QPen(QColor("#ffffff")))
|
||||
font = painter.font()
|
||||
font.setBold(True)
|
||||
font.setPointSize(7)
|
||||
painter.setFont(font)
|
||||
painter.drawText(badge_rect.adjusted(0, -1, 0, 0), Qt.AlignmentFlag.AlignCenter, "W")
|
||||
|
||||
# Значок избранного (жёлтая звезда)
|
||||
if index.data(FavoriteRole):
|
||||
star_rect = QRect(rect.left() + 8, rect.top() + 6, 16, 16)
|
||||
painter.setPen(QPen(QColor("#ffc107")))
|
||||
font = painter.font()
|
||||
font.setPointSize(11)
|
||||
painter.setFont(font)
|
||||
painter.drawText(star_rect, Qt.AlignmentFlag.AlignCenter, "★")
|
||||
|
||||
painter.restore()
|
||||
|
||||
def sizeHint(self, option, index) -> QSize:
|
||||
size = self.model.thumbnail_size
|
||||
return QSize(size, size + 22)
|
||||
|
||||
|
||||
class GalleryListView(QListView):
|
||||
delete_pressed = pyqtSignal(list)
|
||||
copy_pressed = pyqtSignal(str)
|
||||
fullscreen_requested = pyqtSignal(int) # row
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C:
|
||||
indexes = self.selectedIndexes()
|
||||
if indexes:
|
||||
filepath = self.model().data(indexes[0], FilePathRole)
|
||||
if filepath:
|
||||
self.copy_pressed.emit(filepath)
|
||||
event.accept()
|
||||
return
|
||||
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||||
indexes = self.selectedIndexes()
|
||||
if indexes:
|
||||
paths = [self.model().data(idx, FilePathRole) for idx in indexes if idx.isValid()]
|
||||
self.delete_pressed.emit(paths)
|
||||
event.accept()
|
||||
return
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
indexes = self.selectedIndexes()
|
||||
if indexes:
|
||||
self.fullscreen_requested.emit(indexes[0].row())
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
index = self.indexAt(event.pos())
|
||||
if index.isValid():
|
||||
self.fullscreen_requested.emit(index.row())
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def startDrag(self, supportedActions):
|
||||
"""Системный Drag-and-Drop файлов во внешние окна."""
|
||||
indexes = self.selectedIndexes()
|
||||
if not indexes:
|
||||
return
|
||||
drag = QDrag(self)
|
||||
drag.setMimeData(self.model().mimeData(indexes))
|
||||
pixmap = self.model().data(indexes[0], PixmapRole)
|
||||
if pixmap and not pixmap.isNull():
|
||||
drag.setPixmap(pixmap.scaled(70, 70, Qt.AspectRatioMode.KeepAspectRatio))
|
||||
drag.exec(Qt.DropAction.CopyAction)
|
||||
|
||||
|
||||
class CenterGrid(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
control_layout = QHBoxLayout()
|
||||
self.path_label = QLabel(tr("select_folder"))
|
||||
control_layout.addWidget(self.path_label)
|
||||
control_layout.addStretch()
|
||||
|
||||
control_layout.addWidget(QLabel(tr("zoom")))
|
||||
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.zoom_slider.setRange(MIN_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE)
|
||||
self.zoom_slider.setValue(DEFAULT_THUMBNAIL_SIZE)
|
||||
self.zoom_slider.setFixedWidth(150)
|
||||
control_layout.addWidget(self.zoom_slider)
|
||||
layout.addLayout(control_layout)
|
||||
|
||||
self.list_view = GalleryListView()
|
||||
self.list_view.setViewMode(QListView.ViewMode.IconMode)
|
||||
self.list_view.setResizeMode(QListView.ResizeMode.Adjust)
|
||||
self.list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection)
|
||||
self.list_view.setDragEnabled(True)
|
||||
self.list_view.setSpacing(10)
|
||||
self.list_view.setUniformItemSizes(True)
|
||||
self.list_view.setStyleSheet(
|
||||
"QListView { border: 1px solid #2d2d2d; background-color: #121212; }")
|
||||
|
||||
self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
|
||||
self.model = ImageModel()
|
||||
self.delegate = ImageDelegate(self.model)
|
||||
self.list_view.setModel(self.model)
|
||||
self.list_view.setItemDelegate(self.delegate)
|
||||
layout.addWidget(self.list_view)
|
||||
|
||||
self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
|
||||
|
||||
def _on_zoom_changed(self, value: int):
|
||||
self.model.set_thumbnail_size(value)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Левая панель: поиск, выбор корневой папки, дерево подпапок,
|
||||
фильтр избранного и фильтр по тегу.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QLabel, QLineEdit, QTreeView, QComboBox, QCheckBox
|
||||
)
|
||||
from PyQt6.QtGui import QFileSystemModel
|
||||
from PyQt6.QtCore import QDir, pyqtSignal
|
||||
|
||||
from comfy_studio.core.i18n import tr
|
||||
from comfy_studio.ui.widgets.translatable_text_edit import TranslatableLineEdit
|
||||
|
||||
|
||||
class LeftPanel(QWidget):
|
||||
search_changed = pyqtSignal(str)
|
||||
root_folder_changed = pyqtSignal(str)
|
||||
filters_changed = pyqtSignal() # избранное / тег
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
self.search_input = TranslatableLineEdit()
|
||||
self.search_input.setPlaceholderText(tr("search_placeholder"))
|
||||
self.search_input.textChanged.connect(self.search_changed.emit)
|
||||
layout.addWidget(self.search_input)
|
||||
|
||||
# Фильтр избранного
|
||||
self.favorites_check = QCheckBox(tr("filter_favorites"))
|
||||
self.favorites_check.stateChanged.connect(lambda _: self.filters_changed.emit())
|
||||
layout.addWidget(self.favorites_check)
|
||||
|
||||
# Фильтр по тегу
|
||||
layout.addWidget(QLabel(tr("filter_tag")))
|
||||
self.tag_combo = QComboBox()
|
||||
self.tag_combo.addItem(tr("all_tags"), None)
|
||||
self.tag_combo.currentIndexChanged.connect(lambda _: self.filters_changed.emit())
|
||||
layout.addWidget(self.tag_combo)
|
||||
|
||||
# Выбор активной корневой директории
|
||||
layout.addWidget(QLabel(tr("active_folder")))
|
||||
self.folder_selector = QComboBox()
|
||||
self.folder_selector.currentTextChanged.connect(self._on_folder_changed)
|
||||
layout.addWidget(self.folder_selector)
|
||||
|
||||
layout.addWidget(QLabel(tr("subfolder_tree")))
|
||||
|
||||
self.folder_model = QFileSystemModel()
|
||||
self.folder_model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot)
|
||||
|
||||
self.tree_view = QTreeView()
|
||||
self.tree_view.setModel(self.folder_model)
|
||||
self.tree_view.setHeaderHidden(True)
|
||||
|
||||
for i in range(1, self.folder_model.columnCount()):
|
||||
self.tree_view.setColumnHidden(i, True)
|
||||
|
||||
layout.addWidget(self.tree_view)
|
||||
|
||||
# ── Публичные методы ──
|
||||
|
||||
def set_tracked_folders(self, folders: list):
|
||||
self.folder_selector.blockSignals(True)
|
||||
self.folder_selector.clear()
|
||||
self.folder_selector.addItems(folders)
|
||||
self.folder_selector.blockSignals(False)
|
||||
|
||||
def set_root_path(self, path: str):
|
||||
self.folder_model.setRootPath(path)
|
||||
self.tree_view.setRootIndex(self.folder_model.index(path))
|
||||
|
||||
def update_tags(self, tags: list):
|
||||
"""Обновляет список тегов, сохраняя текущий выбор если возможно."""
|
||||
current = self.tag_combo.currentData()
|
||||
self.tag_combo.blockSignals(True)
|
||||
self.tag_combo.clear()
|
||||
self.tag_combo.addItem(tr("all_tags"), None)
|
||||
for t in tags:
|
||||
self.tag_combo.addItem(t, t)
|
||||
if current in tags:
|
||||
self.tag_combo.setCurrentIndex(tags.index(current) + 1)
|
||||
self.tag_combo.blockSignals(False)
|
||||
|
||||
def get_filters(self) -> dict:
|
||||
return {
|
||||
"favorites_only": self.favorites_check.isChecked(),
|
||||
"tag": self.tag_combo.currentData(),
|
||||
}
|
||||
|
||||
def _on_folder_changed(self, path: str):
|
||||
if path:
|
||||
self.set_root_path(path)
|
||||
self.root_folder_changed.emit(path)
|
||||
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Правая панель: превью, параметры генерации, промты, редакторы
|
||||
workflow/prompt JSON, рейтинг, избранное и теги.
|
||||
"""
|
||||
|
||||
import json
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget,
|
||||
QTextEdit, QLineEdit, QPushButton, QFormLayout, QComboBox,
|
||||
QMessageBox, QApplication, QCheckBox
|
||||
)
|
||||
from PyQt6.QtGui import QPixmap
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
|
||||
from comfy_studio.core.i18n import tr
|
||||
from comfy_studio.parsers.metadata_parser import MetadataParser
|
||||
from comfy_studio.ui.widgets.translatable_text_edit import (
|
||||
TranslatableTextEdit, TranslatableLineEdit,
|
||||
)
|
||||
|
||||
|
||||
class TagChip(QWidget):
|
||||
"""Тег с кнопкой удаления."""
|
||||
removed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, tag_name: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.tag_name = tag_name
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(6, 2, 2, 2)
|
||||
layout.setSpacing(4)
|
||||
|
||||
label = QLabel(tag_name)
|
||||
label.setStyleSheet("background: transparent; color: #cde5ff;")
|
||||
layout.addWidget(label)
|
||||
|
||||
btn = QPushButton("×")
|
||||
btn.setFixedSize(16, 16)
|
||||
btn.setStyleSheet(
|
||||
"QPushButton { background: transparent; border: none; color: #90a4ae;"
|
||||
" font-weight: bold; padding: 0; }"
|
||||
"QPushButton:hover { color: #ef5350; }"
|
||||
)
|
||||
btn.clicked.connect(lambda: self.removed.emit(self.tag_name))
|
||||
layout.addWidget(btn)
|
||||
|
||||
self.setStyleSheet(
|
||||
"TagChip { background-color: #1a3a5c; border: 1px solid #2a5a8c;"
|
||||
" border-radius: 9px; }"
|
||||
)
|
||||
|
||||
|
||||
class FlowTagContainer(QWidget):
|
||||
"""Контейнер тегов в строку с переносом."""
|
||||
tag_removed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self._layout = QHBoxLayout(self)
|
||||
self._layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._layout.setSpacing(4)
|
||||
self._layout.addStretch()
|
||||
|
||||
def set_tags(self, tags: list):
|
||||
# Удаляем старые чипы
|
||||
while self._layout.count() > 1:
|
||||
item = self._layout.takeAt(0)
|
||||
if item.widget():
|
||||
item.widget().deleteLater()
|
||||
for t in tags:
|
||||
chip = TagChip(t)
|
||||
chip.removed.connect(self.tag_removed.emit)
|
||||
self._layout.insertWidget(self._layout.count() - 1, chip)
|
||||
|
||||
|
||||
class RightPanel(QWidget):
|
||||
save_clicked = pyqtSignal(int, dict)
|
||||
send_to_comfy_clicked = pyqtSignal(str)
|
||||
favorite_toggled = pyqtSignal(int, bool)
|
||||
tag_added = pyqtSignal(int, str)
|
||||
tag_removed = pyqtSignal(int, str)
|
||||
json_saved = pyqtSignal(int, str, str) # (file_id, field: "workflow_json"|"prompt_json", json_text)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.current_file_id = None
|
||||
self.current_filepath = None
|
||||
self.current_raw_prompt = None
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
self.preview_label = QLabel(tr("no_image_selected"))
|
||||
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.preview_label.setMinimumHeight(250)
|
||||
self.preview_label.setStyleSheet(
|
||||
"QLabel { background-color: #0f0f0f; border: 1px solid #2d2d2d; border-radius: 4px; }"
|
||||
)
|
||||
layout.addWidget(self.preview_label)
|
||||
|
||||
# Избранное
|
||||
self.favorite_btn = QPushButton(tr("favorite"))
|
||||
self.favorite_btn.setCheckable(True)
|
||||
self.favorite_btn.clicked.connect(self._on_favorite_clicked)
|
||||
layout.addWidget(self.favorite_btn)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
# ── Вкладка Generation ──
|
||||
self.gen_tab = QWidget()
|
||||
gen_layout = QVBoxLayout(self.gen_tab)
|
||||
gen_layout.setContentsMargins(5, 5, 5, 5)
|
||||
|
||||
form_layout = QFormLayout()
|
||||
self.model_input = QLineEdit()
|
||||
self.model_input.setReadOnly(True)
|
||||
form_layout.addRow(tr("model"), self.model_input)
|
||||
|
||||
self.sampler_input = QLineEdit()
|
||||
self.sampler_input.setReadOnly(True)
|
||||
form_layout.addRow(tr("sampler"), self.sampler_input)
|
||||
|
||||
params_row = QHBoxLayout()
|
||||
self.seed_input = QLineEdit()
|
||||
self.seed_input.setReadOnly(True)
|
||||
self.steps_input = QLineEdit()
|
||||
self.steps_input.setReadOnly(True)
|
||||
self.cfg_input = QLineEdit()
|
||||
self.cfg_input.setReadOnly(True)
|
||||
|
||||
params_row.addWidget(QLabel("Seed:"))
|
||||
params_row.addWidget(self.seed_input)
|
||||
params_row.addWidget(QLabel("Steps:"))
|
||||
params_row.addWidget(self.steps_input)
|
||||
params_row.addWidget(QLabel("CFG:"))
|
||||
params_row.addWidget(self.cfg_input)
|
||||
form_layout.addRow(params_row)
|
||||
|
||||
self.rating_combo = QComboBox()
|
||||
self.rating_combo.addItems([tr("no_rating"), "★", "★★", "★★★", "★★★★", "★★★★★"])
|
||||
form_layout.addRow(tr("rating"), self.rating_combo)
|
||||
gen_layout.addLayout(form_layout)
|
||||
|
||||
# Теги
|
||||
gen_layout.addWidget(QLabel(tr("tags")))
|
||||
self.tag_container = FlowTagContainer()
|
||||
self.tag_container.tag_removed.connect(self._on_tag_removed)
|
||||
gen_layout.addWidget(self.tag_container)
|
||||
|
||||
self.tag_input = TranslatableLineEdit()
|
||||
self.tag_input.setPlaceholderText(tr("add_tag_placeholder"))
|
||||
self.tag_input.returnPressed.connect(self._on_tag_entered)
|
||||
gen_layout.addWidget(self.tag_input)
|
||||
|
||||
gen_layout.addWidget(QLabel(tr("positive_prompt")))
|
||||
self.positive_text = TranslatableTextEdit()
|
||||
gen_layout.addWidget(self.positive_text)
|
||||
|
||||
gen_layout.addWidget(QLabel(tr("negative_prompt")))
|
||||
self.negative_text = TranslatableTextEdit()
|
||||
gen_layout.addWidget(self.negative_text)
|
||||
|
||||
self.save_btn = QPushButton(tr("save_changes"))
|
||||
self.save_btn.clicked.connect(self._on_save_clicked)
|
||||
gen_layout.addWidget(self.save_btn)
|
||||
|
||||
# ── Вкладка Workflow (редактируемая) ──
|
||||
self.workflow_tab = QWidget()
|
||||
wf_layout = QVBoxLayout(self.workflow_tab)
|
||||
wf_layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.workflow_text = TranslatableTextEdit()
|
||||
wf_layout.addWidget(self.workflow_text)
|
||||
|
||||
wf_btn_layout = QHBoxLayout()
|
||||
self.format_wf_btn = QPushButton(tr("format_json"))
|
||||
self.format_wf_btn.clicked.connect(lambda: self._format_json(self.workflow_text))
|
||||
self.save_wf_btn = QPushButton(tr("save_workflow"))
|
||||
self.save_wf_btn.clicked.connect(
|
||||
lambda: self._save_json_field("workflow_json", self.workflow_text))
|
||||
wf_btn_layout.addWidget(self.format_wf_btn)
|
||||
wf_btn_layout.addWidget(self.save_wf_btn)
|
||||
wf_layout.addLayout(wf_btn_layout)
|
||||
|
||||
# ── Вкладка Prompt JSON (редактируемая) ──
|
||||
self.prompt_tab = QWidget()
|
||||
pr_layout = QVBoxLayout(self.prompt_tab)
|
||||
pr_layout.setContentsMargins(5, 5, 5, 5)
|
||||
self.prompt_json_text = TranslatableTextEdit()
|
||||
pr_layout.addWidget(self.prompt_json_text)
|
||||
|
||||
pr_btn_layout = QHBoxLayout()
|
||||
self.format_pr_btn = QPushButton(tr("format_json"))
|
||||
self.format_pr_btn.clicked.connect(lambda: self._format_json(self.prompt_json_text))
|
||||
self.save_pr_btn = QPushButton(tr("save_prompt_json"))
|
||||
self.save_pr_btn.clicked.connect(
|
||||
lambda: self._save_json_field("prompt_json", self.prompt_json_text))
|
||||
pr_btn_layout.addWidget(self.format_pr_btn)
|
||||
pr_btn_layout.addWidget(self.save_pr_btn)
|
||||
pr_layout.addLayout(pr_btn_layout)
|
||||
|
||||
self.tabs.addTab(self.gen_tab, tr("tab_generation"))
|
||||
self.tabs.addTab(self.workflow_tab, tr("tab_workflow"))
|
||||
self.tabs.addTab(self.prompt_tab, tr("tab_prompt_json"))
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Кнопки копирования
|
||||
export_layout = QHBoxLayout()
|
||||
self.copy_pos_btn = QPushButton(tr("copy_positive"))
|
||||
self.copy_pos_btn.clicked.connect(
|
||||
lambda: QApplication.clipboard().setText(self.positive_text.toPlainText()))
|
||||
self.copy_neg_btn = QPushButton(tr("copy_negative"))
|
||||
self.copy_neg_btn.clicked.connect(
|
||||
lambda: QApplication.clipboard().setText(self.negative_text.toPlainText()))
|
||||
self.copy_wf_btn = QPushButton(tr("copy_workflow"))
|
||||
self.copy_wf_btn.clicked.connect(
|
||||
lambda: QApplication.clipboard().setText(self.workflow_text.toPlainText()))
|
||||
export_layout.addWidget(self.copy_pos_btn)
|
||||
export_layout.addWidget(self.copy_neg_btn)
|
||||
export_layout.addWidget(self.copy_wf_btn)
|
||||
layout.addLayout(export_layout)
|
||||
|
||||
send_layout = QHBoxLayout()
|
||||
self.send_btn = QPushButton(tr("send_to_comfy"))
|
||||
self.send_btn.clicked.connect(self._on_send_clicked)
|
||||
send_layout.addWidget(self.send_btn, 1)
|
||||
|
||||
self.randomize_seed_check = QCheckBox(tr("randomize_seed"))
|
||||
self.randomize_seed_check.setChecked(True)
|
||||
send_layout.addWidget(self.randomize_seed_check)
|
||||
layout.addLayout(send_layout)
|
||||
|
||||
# ── Отображение данных ──
|
||||
|
||||
def display_metadata(self, file_details: Optional[dict]):
|
||||
if not file_details:
|
||||
self.clear_fields()
|
||||
return
|
||||
self.current_file_id = file_details["id"]
|
||||
self.current_filepath = file_details["filepath"]
|
||||
self.current_raw_prompt = file_details.get("prompt_json")
|
||||
|
||||
pixmap = QPixmap(self.current_filepath)
|
||||
if not pixmap.isNull():
|
||||
scaled = pixmap.scaled(
|
||||
self.preview_label.width(), self.preview_label.height(),
|
||||
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.preview_label.setPixmap(scaled)
|
||||
else:
|
||||
self.preview_label.setText(tr("preview_error"))
|
||||
|
||||
self.favorite_btn.setChecked(bool(file_details.get("favorite")))
|
||||
|
||||
self.model_input.setText(file_details.get("model_name") or tr("not_specified"))
|
||||
self.sampler_input.setText(file_details.get("sampler") or tr("not_specified"))
|
||||
self.seed_input.setText(str(file_details.get("seed") or ""))
|
||||
self.steps_input.setText(str(file_details.get("steps") or ""))
|
||||
self.cfg_input.setText(str(file_details.get("cfg") or ""))
|
||||
|
||||
rating = file_details.get("rating", 0)
|
||||
self.rating_combo.setCurrentIndex(rating if 0 <= rating <= 5 else 0)
|
||||
|
||||
self.tag_container.set_tags(file_details.get("tags", []))
|
||||
|
||||
self.positive_text.setPlainText(file_details.get("positive_prompt") or "")
|
||||
self.negative_text.setPlainText(file_details.get("negative_prompt") or "")
|
||||
|
||||
self._display_json(self.workflow_text, file_details.get("workflow_json"),
|
||||
tr("workflow_not_found"))
|
||||
self._display_json(self.prompt_json_text, file_details.get("prompt_json"),
|
||||
tr("prompt_json_not_found"))
|
||||
has_wf = bool(file_details.get("workflow_json"))
|
||||
has_pr = bool(file_details.get("prompt_json"))
|
||||
self.save_wf_btn.setEnabled(True)
|
||||
self.save_pr_btn.setEnabled(True)
|
||||
self.format_wf_btn.setEnabled(has_wf)
|
||||
self.format_pr_btn.setEnabled(has_pr)
|
||||
|
||||
@staticmethod
|
||||
def _display_json(widget: QTextEdit, raw: Optional[str], placeholder: str):
|
||||
if raw:
|
||||
try:
|
||||
widget.setPlainText(json.dumps(json.loads(raw), indent=2, ensure_ascii=False))
|
||||
except Exception:
|
||||
widget.setPlainText(raw)
|
||||
else:
|
||||
widget.clear()
|
||||
widget.setPlaceholderText(placeholder)
|
||||
|
||||
def clear_fields(self):
|
||||
self.current_file_id = None
|
||||
self.current_filepath = None
|
||||
self.current_raw_prompt = None
|
||||
self.preview_label.setText(tr("no_image_selected"))
|
||||
self.preview_label.setPixmap(QPixmap())
|
||||
self.favorite_btn.setChecked(False)
|
||||
self.model_input.clear()
|
||||
self.sampler_input.clear()
|
||||
self.seed_input.clear()
|
||||
self.steps_input.clear()
|
||||
self.cfg_input.clear()
|
||||
self.rating_combo.setCurrentIndex(0)
|
||||
self.tag_container.set_tags([])
|
||||
self.tag_input.clear()
|
||||
self.positive_text.clear()
|
||||
self.negative_text.clear()
|
||||
self.workflow_text.clear()
|
||||
self.prompt_json_text.clear()
|
||||
self.save_wf_btn.setEnabled(False)
|
||||
self.save_pr_btn.setEnabled(False)
|
||||
self.format_wf_btn.setEnabled(False)
|
||||
self.format_pr_btn.setEnabled(False)
|
||||
|
||||
# ── Редактор JSON ──
|
||||
|
||||
def _format_json(self, widget: QTextEdit):
|
||||
"""Валидирует и красиво форматирует JSON в редакторе."""
|
||||
text = widget.toPlainText().strip()
|
||||
if not text:
|
||||
return
|
||||
try:
|
||||
widget.setPlainText(json.dumps(json.loads(text), indent=2, ensure_ascii=False))
|
||||
except json.JSONDecodeError as e:
|
||||
QMessageBox.warning(self, tr("error"), tr("invalid_json", err=str(e)))
|
||||
|
||||
def _save_json_field(self, field: str, widget: QTextEdit):
|
||||
"""Проверяет JSON и передаёт его на сохранение в БД."""
|
||||
if self.current_file_id is None:
|
||||
return
|
||||
text = widget.toPlainText().strip()
|
||||
if text:
|
||||
try:
|
||||
# Сохраняем компактную форму, как пишет ComfyUI
|
||||
text = json.dumps(json.loads(text), ensure_ascii=False)
|
||||
except json.JSONDecodeError as e:
|
||||
QMessageBox.warning(self, tr("error"), tr("invalid_json", err=str(e)))
|
||||
return
|
||||
if field == "prompt_json":
|
||||
self.current_raw_prompt = text or None
|
||||
self.json_saved.emit(self.current_file_id, field, text)
|
||||
|
||||
# ── Обработчики ──
|
||||
|
||||
def _on_save_clicked(self):
|
||||
if self.current_file_id is None:
|
||||
return
|
||||
payload = {
|
||||
"positive_prompt": self.positive_text.toPlainText(),
|
||||
"negative_prompt": self.negative_text.toPlainText(),
|
||||
"rating": self.rating_combo.currentIndex()
|
||||
}
|
||||
self.save_clicked.emit(self.current_file_id, payload)
|
||||
|
||||
def _on_send_clicked(self):
|
||||
if not self.current_raw_prompt:
|
||||
QMessageBox.warning(self, tr("error"), tr("no_prompt_graph"))
|
||||
return
|
||||
# Внедряем текущие (возможно отредактированные) промты в граф.
|
||||
# Без нового seed ComfyUI отдаёт идентичный граф из кэша
|
||||
# и не генерирует ("Prompt executed in 0.00 seconds").
|
||||
seed = random.randint(0, 2**48) if self.randomize_seed_check.isChecked() else None
|
||||
updated_prompt = MetadataParser.inject_parameters(
|
||||
self.current_raw_prompt,
|
||||
positive=self.positive_text.toPlainText(),
|
||||
negative=self.negative_text.toPlainText(),
|
||||
seed=seed,
|
||||
)
|
||||
self.send_to_comfy_clicked.emit(updated_prompt)
|
||||
|
||||
def _on_favorite_clicked(self, checked: bool):
|
||||
if self.current_file_id is not None:
|
||||
self.favorite_toggled.emit(self.current_file_id, checked)
|
||||
|
||||
def _on_tag_entered(self):
|
||||
if self.current_file_id is None:
|
||||
return
|
||||
tag = self.tag_input.text().strip()
|
||||
if tag:
|
||||
self.tag_added.emit(self.current_file_id, tag)
|
||||
self.tag_input.clear()
|
||||
|
||||
def _on_tag_removed(self, tag: str):
|
||||
if self.current_file_id is not None:
|
||||
self.tag_removed.emit(self.current_file_id, tag)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Диалог настроек: URL ComfyUI, отслеживаемые папки, язык интерфейса,
|
||||
языки перевода в контекстном меню.
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
|
||||
QPushButton, QListWidget, QListWidgetItem, QFileDialog,
|
||||
QMessageBox, QComboBox
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from comfy_studio.core.i18n import tr
|
||||
from comfy_studio.services.translator import LANG_NAMES
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
def __init__(self, current_settings: dict, parent=None):
|
||||
super().__init__(parent)
|
||||
self.settings = current_settings
|
||||
self.setWindowTitle(tr("settings_title"))
|
||||
self.resize(520, 420)
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(QLabel(tr("comfy_api_url")))
|
||||
self.url_input = QLineEdit()
|
||||
self.url_input.setText(self.settings.get("comfyui_url", "http://127.0.0.1:8188"))
|
||||
layout.addWidget(self.url_input)
|
||||
|
||||
# Язык интерфейса
|
||||
layout.addWidget(QLabel(tr("language")))
|
||||
self.lang_combo = QComboBox()
|
||||
self.lang_combo.addItem("Русский", "ru")
|
||||
self.lang_combo.addItem("English", "en")
|
||||
current_lang = self.settings.get("language", "ru")
|
||||
self.lang_combo.setCurrentIndex(0 if current_lang == "ru" else 1)
|
||||
layout.addWidget(self.lang_combo)
|
||||
|
||||
note = QLabel(tr("language_restart_note"))
|
||||
note.setStyleSheet("color: #9e9e9e; font-size: 11px;")
|
||||
layout.addWidget(note)
|
||||
|
||||
# Языки перевода (мультивыбор чекбоксами)
|
||||
layout.addWidget(QLabel(tr("translation_languages")))
|
||||
self.translate_langs_list = QListWidget()
|
||||
self.translate_langs_list.setMaximumHeight(120)
|
||||
selected_langs = self.settings.get("translate_languages", ["ru", "en"])
|
||||
for code, name in LANG_NAMES.items():
|
||||
item = QListWidgetItem(f"{name} ({code})")
|
||||
item.setData(Qt.ItemDataRole.UserRole, code)
|
||||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||||
item.setCheckState(
|
||||
Qt.CheckState.Checked if code in selected_langs
|
||||
else Qt.CheckState.Unchecked)
|
||||
self.translate_langs_list.addItem(item)
|
||||
layout.addWidget(self.translate_langs_list)
|
||||
|
||||
layout.addWidget(QLabel(tr("tracked_folders")))
|
||||
self.paths_list = QListWidget()
|
||||
for p in self.settings.get("tracked_paths", []):
|
||||
self.paths_list.addItem(p)
|
||||
layout.addWidget(self.paths_list)
|
||||
|
||||
paths_btn_layout = QHBoxLayout()
|
||||
self.add_path_btn = QPushButton(tr("add_folder"))
|
||||
self.add_path_btn.clicked.connect(self._add_path)
|
||||
self.remove_path_btn = QPushButton(tr("remove_selected"))
|
||||
self.remove_path_btn.clicked.connect(self._remove_path)
|
||||
paths_btn_layout.addWidget(self.add_path_btn)
|
||||
paths_btn_layout.addWidget(self.remove_path_btn)
|
||||
layout.addLayout(paths_btn_layout)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
self.save_btn = QPushButton(tr("save"))
|
||||
self.save_btn.clicked.connect(self._save)
|
||||
self.cancel_btn = QPushButton(tr("cancel"))
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addStretch()
|
||||
btn_layout.addWidget(self.save_btn)
|
||||
btn_layout.addWidget(self.cancel_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _add_path(self):
|
||||
dir_path = QFileDialog.getExistingDirectory(self, tr("choose_tracked_folder"))
|
||||
if dir_path:
|
||||
items = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
|
||||
if dir_path not in items:
|
||||
self.paths_list.addItem(dir_path)
|
||||
|
||||
def _remove_path(self):
|
||||
for s in self.paths_list.selectedItems():
|
||||
self.paths_list.takeItem(self.paths_list.row(s))
|
||||
|
||||
def _save(self):
|
||||
url = self.url_input.text().strip()
|
||||
if not url:
|
||||
QMessageBox.warning(self, tr("error"), tr("url_empty_error"))
|
||||
return
|
||||
|
||||
paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
|
||||
if not paths:
|
||||
QMessageBox.warning(self, tr("error"), tr("paths_empty_error"))
|
||||
return
|
||||
|
||||
translate_langs = []
|
||||
for i in range(self.translate_langs_list.count()):
|
||||
item = self.translate_langs_list.item(i)
|
||||
if item.checkState() == Qt.CheckState.Checked:
|
||||
translate_langs.append(item.data(Qt.ItemDataRole.UserRole))
|
||||
|
||||
self.settings["comfyui_url"] = url
|
||||
self.settings["tracked_paths"] = paths
|
||||
self.settings["language"] = self.lang_combo.currentData()
|
||||
self.settings["translate_languages"] = translate_langs or ["ru", "en"]
|
||||
self.accept()
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
QTextEdit / QLineEdit с пунктами «Перевести на …» в контекстном меню.
|
||||
Пункты появляются при наличии выделенного текста; список языков
|
||||
настраивается глобально через set_translate_languages() (из настроек).
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from PyQt6.QtWidgets import QTextEdit, QLineEdit
|
||||
|
||||
from comfy_studio.core.i18n import tr
|
||||
from comfy_studio.services.translator import LANG_NAMES
|
||||
from comfy_studio.ui.widgets.translate_dialog import TranslateDialog
|
||||
|
||||
# Глобальный список языков перевода (обновляется из настроек)
|
||||
_translate_languages: List[str] = ["ru", "en"]
|
||||
|
||||
|
||||
def set_translate_languages(langs: List[str]) -> None:
|
||||
global _translate_languages
|
||||
_translate_languages = [l for l in langs if l in LANG_NAMES] or ["ru", "en"]
|
||||
|
||||
|
||||
def get_translate_languages() -> List[str]:
|
||||
return list(_translate_languages)
|
||||
|
||||
|
||||
class TranslatableTextEdit(QTextEdit):
|
||||
"""QTextEdit с переводом выделенного текста через контекстное меню."""
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = self.createStandardContextMenu()
|
||||
|
||||
cursor = self.textCursor()
|
||||
selected = cursor.selectedText()
|
||||
if selected.strip() and not self.isReadOnly():
|
||||
menu.addSeparator()
|
||||
for lang in _translate_languages:
|
||||
lang_name = LANG_NAMES.get(lang, lang)
|
||||
action = menu.addAction(tr("translate_to", lang=lang_name))
|
||||
action.setData(lang)
|
||||
|
||||
chosen = menu.exec(event.globalPos())
|
||||
if chosen and chosen.data() in _translate_languages:
|
||||
self._translate_selection(chosen.data())
|
||||
|
||||
def _translate_selection(self, target_lang: str):
|
||||
cursor = self.textCursor()
|
||||
# QTextCursor.selectedText() заменяет переводы строк на U+2029
|
||||
selected = cursor.selectedText().replace("
", "\n")
|
||||
if not selected.strip():
|
||||
return
|
||||
dialog = TranslateDialog(selected, target_lang, self)
|
||||
if dialog.exec() == TranslateDialog.DialogCode.Accepted:
|
||||
cursor.insertText(dialog.translated_text())
|
||||
|
||||
|
||||
class TranslatableLineEdit(QLineEdit):
|
||||
"""QLineEdit с переводом выделенного текста через контекстное меню."""
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
menu = self.createStandardContextMenu()
|
||||
|
||||
selected = self.selectedText()
|
||||
if selected.strip() and not self.isReadOnly():
|
||||
menu.addSeparator()
|
||||
for lang in _translate_languages:
|
||||
lang_name = LANG_NAMES.get(lang, lang)
|
||||
action = menu.addAction(tr("translate_to", lang=lang_name))
|
||||
action.setData(lang)
|
||||
|
||||
chosen = menu.exec(event.globalPos())
|
||||
if chosen and chosen.data() in _translate_languages:
|
||||
self._translate_selection(chosen.data())
|
||||
|
||||
def _translate_selection(self, target_lang: str):
|
||||
selected = self.selectedText()
|
||||
if not selected.strip():
|
||||
return
|
||||
dialog = TranslateDialog(selected, target_lang, self)
|
||||
if dialog.exec() == TranslateDialog.DialogCode.Accepted:
|
||||
self.insert(dialog.translated_text()) # заменяет выделение
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Диалог перевода выделенного текста:
|
||||
показывает оригинал и редактируемый перевод; «Применить» / «Отменить».
|
||||
"""
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton
|
||||
)
|
||||
|
||||
from comfy_studio.core.i18n import tr
|
||||
from comfy_studio.services.translator import TranslateWorker, LANG_NAMES
|
||||
|
||||
|
||||
class TranslateDialog(QDialog):
|
||||
"""
|
||||
Запускает фоновый перевод текста и позволяет отредактировать результат.
|
||||
После exec() == Accepted переведённый текст доступен через translated_text().
|
||||
"""
|
||||
|
||||
def __init__(self, source_text: str, target_lang: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.source_text = source_text
|
||||
self.target_lang = target_lang
|
||||
self.worker = None
|
||||
|
||||
lang_name = LANG_NAMES.get(target_lang, target_lang)
|
||||
self.setWindowTitle(f"{tr('translation_title')} → {lang_name}")
|
||||
self.resize(560, 440)
|
||||
self.init_ui()
|
||||
self._start_translation()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
layout.addWidget(QLabel(tr("original_text")))
|
||||
self.original_edit = QTextEdit()
|
||||
self.original_edit.setPlainText(self.source_text)
|
||||
self.original_edit.setReadOnly(True)
|
||||
self.original_edit.setMaximumHeight(140)
|
||||
layout.addWidget(self.original_edit)
|
||||
|
||||
layout.addWidget(QLabel(tr("translated_text")))
|
||||
self.translated_edit = QTextEdit()
|
||||
self.translated_edit.setPlainText(tr("translating"))
|
||||
self.translated_edit.setEnabled(False)
|
||||
layout.addWidget(self.translated_edit)
|
||||
|
||||
btn_layout = QHBoxLayout()
|
||||
btn_layout.addStretch()
|
||||
self.apply_btn = QPushButton(tr("apply"))
|
||||
self.apply_btn.setEnabled(False)
|
||||
self.apply_btn.clicked.connect(self.accept)
|
||||
self.cancel_btn = QPushButton(tr("cancel"))
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(self.apply_btn)
|
||||
btn_layout.addWidget(self.cancel_btn)
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _start_translation(self):
|
||||
self.worker = TranslateWorker(self.source_text, self.target_lang, self)
|
||||
self.worker.finished.connect(self._on_translation_finished)
|
||||
self.worker.start()
|
||||
|
||||
def _on_translation_finished(self, success: bool, result: str):
|
||||
if success:
|
||||
self.translated_edit.setPlainText(result)
|
||||
self.translated_edit.setEnabled(True)
|
||||
self.apply_btn.setEnabled(True)
|
||||
self.translated_edit.setFocus()
|
||||
else:
|
||||
self.translated_edit.setPlainText(f"{tr('translation_failed')}:\n{result}")
|
||||
|
||||
def translated_text(self) -> str:
|
||||
return self.translated_edit.toPlainText()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.worker and self.worker.isRunning():
|
||||
self.worker.finished.disconnect()
|
||||
self.worker.quit()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Полноэкранный просмотрщик изображений.
|
||||
Стрелки ←/→ — листание, Esc / двойной клик — выход, колесо мыши — листание.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PyQt6.QtGui import QPixmap, QKeyEvent
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FullscreenViewer(QWidget):
|
||||
def __init__(self, filepaths: List[str], start_index: int = 0, parent=None):
|
||||
super().__init__(parent)
|
||||
self.filepaths = filepaths
|
||||
self.index = max(0, min(start_index, len(filepaths) - 1))
|
||||
|
||||
self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint)
|
||||
self.setStyleSheet("background-color: #000000;")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.image_label = QLabel()
|
||||
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.image_label.setStyleSheet("background-color: #000000;")
|
||||
layout.addWidget(self.image_label, 1)
|
||||
|
||||
self.info_label = QLabel()
|
||||
self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.info_label.setStyleSheet(
|
||||
"color: #9e9e9e; background-color: #000000; padding: 6px; font-size: 12px;")
|
||||
layout.addWidget(self.info_label)
|
||||
|
||||
self._current_pixmap = None
|
||||
|
||||
def open_fullscreen(self):
|
||||
self.showFullScreen()
|
||||
self._load_current()
|
||||
|
||||
def _load_current(self):
|
||||
if not self.filepaths:
|
||||
return
|
||||
path = self.filepaths[self.index]
|
||||
pixmap = QPixmap(path)
|
||||
if pixmap.isNull():
|
||||
self.image_label.setText("⚠")
|
||||
self._current_pixmap = None
|
||||
else:
|
||||
self._current_pixmap = pixmap
|
||||
self._rescale()
|
||||
self.info_label.setText(f"{self.index + 1} / {len(self.filepaths)} — {path}")
|
||||
|
||||
def _rescale(self):
|
||||
if self._current_pixmap:
|
||||
scaled = self._current_pixmap.scaled(
|
||||
self.image_label.size(),
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.image_label.setPixmap(scaled)
|
||||
|
||||
def resizeEvent(self, event):
|
||||
self._rescale()
|
||||
super().resizeEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent):
|
||||
key = event.key()
|
||||
if key == Qt.Key.Key_Escape:
|
||||
self.close()
|
||||
elif key in (Qt.Key.Key_Right, Qt.Key.Key_Down, Qt.Key.Key_Space):
|
||||
self._next()
|
||||
elif key in (Qt.Key.Key_Left, Qt.Key.Key_Up):
|
||||
self._prev()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def wheelEvent(self, event):
|
||||
if event.angleDelta().y() < 0:
|
||||
self._next()
|
||||
else:
|
||||
self._prev()
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
self.close()
|
||||
|
||||
def _next(self):
|
||||
if self.index < len(self.filepaths) - 1:
|
||||
self.index += 1
|
||||
self._load_current()
|
||||
|
||||
def _prev(self):
|
||||
if self.index > 0:
|
||||
self.index -= 1
|
||||
self._load_current()
|
||||
Reference in New Issue
Block a user