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:
dinlo
2026-07-02 14:09:57 +08:00
commit 34a40ae759
29 changed files with 3109 additions and 0 deletions
View File
+377
View File
@@ -0,0 +1,377 @@
"""
Главное окно Comfy-Studio: трёхпанельный интерфейс,
контекстное меню, полноэкранный просмотр, интеграция с ComfyUI.
"""
import os
import sys
import logging
import subprocess
from pathlib import Path
from PyQt6.QtWidgets import (
QMainWindow, QSplitter, QWidget, QHBoxLayout, QVBoxLayout,
QStatusBar, QMessageBox, QApplication, QMenu, QInputDialog,
QLineEdit, QPushButton
)
from PyQt6.QtCore import Qt, QModelIndex, QMimeData, QUrl
from comfy_studio.core.config import DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT, APP_NAME
from comfy_studio.core.i18n import tr
from comfy_studio.core.settings import load_settings, save_settings
from comfy_studio.db.manager import DBManager
from comfy_studio.services.watcher import FolderWatcher
from comfy_studio.services.comfy_api import ComfyAPIClient, ComfySendWorker
from comfy_studio.ui.theme import DARK_THEME_QSS
from comfy_studio.ui.widgets.left_panel import LeftPanel
from comfy_studio.ui.widgets.center_grid import CenterGrid, FilePathRole, FileIdRole
from comfy_studio.ui.widgets.right_panel import RightPanel
from comfy_studio.ui.widgets.settings_dialog import SettingsDialog
from comfy_studio.ui.widgets.viewer import FullscreenViewer
from comfy_studio.ui.widgets.translatable_text_edit import set_translate_languages
logger = logging.getLogger(__name__)
class MainWindow(QMainWindow):
def __init__(self, db_manager: DBManager, watcher: FolderWatcher):
super().__init__()
self.db_manager = db_manager
self.watcher = watcher
self.settings = load_settings()
self.current_root_path = None
self.api_client = ComfyAPIClient(self.settings["comfyui_url"])
set_translate_languages(self.settings.get("translate_languages", ["ru", "en"]))
self.viewer = None
self.comfy_sender = None
self.setWindowTitle(APP_NAME)
self.resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT)
self.setStyleSheet(DARK_THEME_QSS)
self.init_ui()
self.bind_events()
self.left_panel.set_tracked_folders(self.settings["tracked_paths"])
self.left_panel.update_tags(self.db_manager.get_all_tags())
logger.info("Главное окно инициализировано.")
def init_ui(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(8, 8, 8, 8)
top_bar = QHBoxLayout()
self.settings_btn = QPushButton(tr("settings_btn"))
top_bar.addWidget(self.settings_btn)
top_bar.addStretch()
main_layout.addLayout(top_bar)
self.splitter = QSplitter(Qt.Orientation.Horizontal)
self.left_panel = LeftPanel()
self.center_grid = CenterGrid()
self.right_panel = RightPanel()
self.splitter.addWidget(self.left_panel)
self.splitter.addWidget(self.center_grid)
self.splitter.addWidget(self.right_panel)
self.splitter.setSizes([240, 760, 380])
main_layout.addWidget(self.splitter)
self.status_bar = QStatusBar()
self.setStatusBar(self.status_bar)
self.status_bar.showMessage(tr("app_ready"))
def bind_events(self):
# Левая панель
self.left_panel.tree_view.clicked.connect(self._on_subfolder_selected)
self.left_panel.root_folder_changed.connect(self.load_folder_images)
self.left_panel.search_changed.connect(self._on_search)
self.left_panel.filters_changed.connect(self._reload_current)
# Центральный грид
self.center_grid.list_view.selectionModel().selectionChanged.connect(
self._on_image_selected)
self.center_grid.list_view.copy_pressed.connect(self._on_copy_file)
self.center_grid.list_view.delete_pressed.connect(self._on_delete_files)
self.center_grid.list_view.fullscreen_requested.connect(self._open_fullscreen)
self.center_grid.list_view.customContextMenuRequested.connect(self._on_context_menu)
# Правая панель
self.right_panel.save_clicked.connect(self._on_save_metadata)
self.right_panel.send_to_comfy_clicked.connect(self._on_send_to_comfy)
self.right_panel.favorite_toggled.connect(self._on_favorite_toggled)
self.right_panel.tag_added.connect(self._on_tag_added)
self.right_panel.tag_removed.connect(self._on_tag_removed)
self.right_panel.json_saved.connect(self._on_json_saved)
self.settings_btn.clicked.connect(self._open_settings)
# Реактивное обновление от watcher
self.watcher.signals.file_added.connect(self._on_file_changed_externally)
self.watcher.signals.file_removed.connect(self._on_file_changed_externally)
# ────────────────────────────────
# Загрузка изображений
# ────────────────────────────────
def load_folder_images(self, folder_path: str, search_query: str = ""):
self.current_root_path = folder_path
self.center_grid.path_label.setText(f"{tr('folder_label')}: {folder_path}")
filters = self.left_panel.get_filters()
files = self.db_manager.get_files_in_folder(
folder_path, search_query,
favorites_only=filters["favorites_only"],
tag=filters["tag"],
)
self.center_grid.model.set_files(files)
self.status_bar.showMessage(f"{tr('files_shown')}: {len(files)}")
def _reload_current(self):
if self.current_root_path:
self.load_folder_images(self.current_root_path,
self.left_panel.search_input.text())
def _on_subfolder_selected(self, index: QModelIndex):
folder_path = self.left_panel.folder_model.filePath(index)
self.load_folder_images(folder_path)
def _on_search(self, query: str):
if self.current_root_path:
self.load_folder_images(self.current_root_path, query)
def _on_image_selected(self):
indexes = self.center_grid.list_view.selectedIndexes()
if not indexes:
self.right_panel.clear_fields()
return
file_id = self.center_grid.model.data(indexes[0], FileIdRole)
if file_id:
details = self.db_manager.get_file_details(file_id)
self.right_panel.display_metadata(details)
def _on_file_changed_externally(self, filepath: str):
if not self.current_root_path:
return
file_dir = str(Path(filepath).parent.resolve().as_posix())
current_dir = str(Path(self.current_root_path).resolve().as_posix())
if file_dir == current_dir:
self._reload_current()
# ────────────────────────────────
# Полноэкранный просмотр
# ────────────────────────────────
def _open_fullscreen(self, row: int):
paths = [f["filepath"] for f in self.center_grid.model.files]
if not paths:
return
self.viewer = FullscreenViewer(paths, row)
self.viewer.open_fullscreen()
# ────────────────────────────────
# Контекстное меню и CRUD
# ────────────────────────────────
def _on_context_menu(self, position):
indexes = self.center_grid.list_view.selectedIndexes()
if not indexes:
return
menu = QMenu(self)
fullscreen_action = menu.addAction(tr("open_fullscreen"))
fullscreen_action.setEnabled(len(indexes) == 1)
favorite_action = menu.addAction(tr("toggle_favorite"))
show_action = menu.addAction(tr("show_on_disk"))
show_action.setEnabled(len(indexes) == 1)
menu.addSeparator()
rename_action = menu.addAction(tr("rename"))
rename_action.setEnabled(len(indexes) == 1)
delete_action = menu.addAction(tr("delete_selected"))
action = menu.exec(self.center_grid.list_view.mapToGlobal(position))
if action == fullscreen_action:
self._open_fullscreen(indexes[0].row())
elif action == favorite_action:
for idx in indexes:
file_id = self.center_grid.model.data(idx, FileIdRole)
details = self.db_manager.get_file_details(file_id)
if details:
self.db_manager.set_favorite(file_id, not details["favorite"])
self._reload_current()
self._on_image_selected()
elif action == show_action:
filepath = self.center_grid.model.data(indexes[0], FilePathRole)
self._show_in_explorer(filepath)
elif action == rename_action:
idx = indexes[0]
filepath = self.center_grid.model.data(idx, FilePathRole)
filename = self.center_grid.model.data(idx, Qt.ItemDataRole.DisplayRole)
new_name, ok = QInputDialog.getText(
self, tr("rename_title"), f"{tr('rename_prompt')} {filename}:",
QLineEdit.EchoMode.Normal, filename
)
if ok and new_name.strip() and new_name != filename:
self._rename_file(filepath, new_name.strip())
elif action == delete_action:
paths = [self.center_grid.model.data(idx, FilePathRole) for idx in indexes]
self._on_delete_files(paths)
def _show_in_explorer(self, filepath: str):
"""Открывает файловый менеджер ОС с выделенным файлом."""
norm_path = os.path.normpath(filepath)
if not os.path.exists(norm_path):
return
try:
if sys.platform == "win32":
subprocess.run(f'explorer /select,"{norm_path}"', shell=True)
elif sys.platform == "darwin":
subprocess.run(["open", "-R", norm_path])
else:
subprocess.run(["xdg-open", os.path.dirname(norm_path)])
except Exception as e:
logger.error(f"Не удалось открыть файловый менеджер: {e}")
def _rename_file(self, old_filepath: str, new_filename: str):
old_path = Path(old_filepath)
new_filepath = old_path.parent / new_filename
if not new_filepath.suffix:
new_filepath = new_filepath.with_suffix(old_path.suffix)
try:
os.rename(str(old_path), str(new_filepath))
self.status_bar.showMessage(f"{tr('renamed_ok')}: {new_filepath.name}")
self._reload_current()
except Exception as e:
logger.error(f"Ошибка переименования {old_path.name}: {e}")
QMessageBox.critical(self, tr("error"), f"{tr('rename_failed')}: {e}")
def _on_copy_file(self, filepath: str):
if os.path.exists(filepath):
mime_data = QMimeData()
mime_data.setUrls([QUrl.fromLocalFile(filepath)])
QApplication.clipboard().setMimeData(mime_data)
self.status_bar.showMessage(tr("copied_to_clipboard"))
def _on_delete_files(self, filepaths: list):
if not filepaths:
return
confirm = QMessageBox.question(
self, tr("delete_confirm_title"),
tr("delete_confirm_text", n=len(filepaths)),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm == QMessageBox.StandardButton.Yes:
deleted_count = 0
for path in filepaths:
try:
if os.path.exists(path):
os.remove(path)
deleted_count += 1
except Exception as e:
logger.error(f"Ошибка удаления файла {path}: {e}")
self.status_bar.showMessage(f"{tr('deleted_from_disk')}: {deleted_count}")
self._reload_current()
# ────────────────────────────────
# Метаданные / избранное / теги
# ────────────────────────────────
def _on_save_metadata(self, file_id: int, payload: dict):
success = self.db_manager.update_file_details(
file_id, payload["positive_prompt"], payload["negative_prompt"], payload["rating"]
)
if success:
self.status_bar.showMessage(tr("params_updated"))
details = self.db_manager.get_file_details(file_id)
self.right_panel.display_metadata(details)
self._reload_current()
else:
QMessageBox.critical(self, tr("error"), tr("db_save_error"))
def _on_json_saved(self, file_id: int, field: str, json_text: str):
"""Сохранение отредактированного workflow/prompt JSON в БД."""
success = self.db_manager.update_json_field(file_id, field, json_text or None)
if success:
self.status_bar.showMessage(tr("json_saved"))
details = self.db_manager.get_file_details(file_id)
self.right_panel.display_metadata(details)
self._reload_current()
else:
QMessageBox.critical(self, tr("error"), tr("db_save_error"))
def _on_favorite_toggled(self, file_id: int, favorite: bool):
self.db_manager.set_favorite(file_id, favorite)
self._reload_current()
def _on_tag_added(self, file_id: int, tag: str):
self.db_manager.add_tag_to_file(file_id, tag)
details = self.db_manager.get_file_details(file_id)
self.right_panel.display_metadata(details)
self.left_panel.update_tags(self.db_manager.get_all_tags())
def _on_tag_removed(self, file_id: int, tag: str):
self.db_manager.remove_tag_from_file(file_id, tag)
details = self.db_manager.get_file_details(file_id)
self.right_panel.display_metadata(details)
self.left_panel.update_tags(self.db_manager.get_all_tags())
self._reload_current()
# ────────────────────────────────
# Настройки
# ────────────────────────────────
def _open_settings(self):
dialog = SettingsDialog(self.settings, self)
if dialog.exec() == SettingsDialog.DialogCode.Accepted:
save_settings(self.settings)
self.api_client = ComfyAPIClient(self.settings["comfyui_url"])
set_translate_languages(self.settings.get("translate_languages", ["ru", "en"]))
self.left_panel.set_tracked_folders(self.settings["tracked_paths"])
self.status_bar.showMessage(tr("watcher_restarting"))
self.watcher.stop_monitoring()
self.watcher.start_monitoring(self.settings["tracked_paths"])
if self.settings["tracked_paths"]:
first_path = self.settings["tracked_paths"][0]
self.left_panel.set_root_path(first_path)
self.load_folder_images(first_path)
self.status_bar.showMessage(tr("settings_applied"))
# ────────────────────────────────
# ComfyUI
# ────────────────────────────────
def _on_send_to_comfy(self, prompt_json: str):
self.status_bar.showMessage(tr("sending_prompt"))
self.right_panel.send_btn.setEnabled(False)
self.comfy_sender = ComfySendWorker(self.api_client, prompt_json)
self.comfy_sender.finished.connect(self._on_send_finished)
self.comfy_sender.start()
def _on_send_finished(self, success: bool, message: str):
self.right_panel.send_btn.setEnabled(True)
if success:
self.status_bar.showMessage(tr("prompt_queued"))
QMessageBox.information(self, tr("success"), message)
else:
self.status_bar.showMessage(tr("prompt_send_failed"))
QMessageBox.critical(self, tr("network_error_title"), message)
+136
View File
@@ -0,0 +1,136 @@
"""Тёмная тема оформления (QSS)."""
DARK_THEME_QSS = """
QMainWindow {
background-color: #121212;
}
QWidget {
background-color: #121212;
color: #e0e0e0;
font-family: 'Segoe UI', Arial, sans-serif;
font-size: 13px;
}
QSplitter::handle {
background-color: #252525;
}
QSplitter::handle:horizontal {
width: 6px;
}
QTreeView {
background-color: #1e1e1e;
border: 1px solid #2d2d2d;
border-radius: 4px;
}
QTreeView::item:hover {
background-color: #2a2a2a;
}
QTreeView::item:selected {
background-color: #0d47a1;
color: #ffffff;
}
QListWidget {
background-color: #1e1e1e;
border: 1px solid #2d2d2d;
border-radius: 4px;
}
QLineEdit {
background-color: #242424;
border: 1px solid #3d3d3d;
border-radius: 4px;
padding: 6px;
color: #ffffff;
}
QLineEdit:focus {
border: 1px solid #1976d2;
}
QTabWidget::pane {
border: 1px solid #2d2d2d;
background-color: #1a1a1a;
border-radius: 4px;
}
QTabBar::tab {
background-color: #151515;
color: #888888;
padding: 8px 16px;
border: 1px solid #2d2d2d;
border-bottom: none;
margin-right: 2px;
}
QTabBar::tab:selected {
background-color: #1a1a1a;
color: #ffffff;
border-bottom: 2px solid #1976d2;
}
QTextEdit {
background-color: #181818;
border: 1px solid #2d2d2d;
border-radius: 4px;
color: #e0e0e0;
font-family: 'Consolas', monospace;
}
QPushButton {
background-color: #242424;
border: 1px solid #3d3d3d;
border-radius: 4px;
padding: 8px 14px;
color: #ffffff;
font-weight: 500;
}
QPushButton:hover {
background-color: #2d2d2d;
border-color: #555555;
}
QPushButton:pressed {
background-color: #1976d2;
}
QPushButton:checked {
background-color: #0d47a1;
border-color: #1976d2;
}
QComboBox {
background-color: #242424;
border: 1px solid #3d3d3d;
border-radius: 4px;
padding: 4px;
color: #ffffff;
}
QComboBox QAbstractItemView {
background-color: #242424;
color: #ffffff;
selection-background-color: #0d47a1;
}
QCheckBox {
spacing: 6px;
}
QCheckBox::indicator {
width: 16px;
height: 16px;
border: 1px solid #3d3d3d;
border-radius: 3px;
background-color: #242424;
}
QCheckBox::indicator:checked {
background-color: #1976d2;
border-color: #1976d2;
}
QScrollBar:vertical {
border: none;
background-color: #121212;
width: 8px;
}
QScrollBar::handle:vertical {
background-color: #3a3a3a;
border-radius: 4px;
}
QStatusBar {
background-color: #181818;
color: #9e9e9e;
}
QMenu {
background-color: #1e1e1e;
border: 1px solid #2d2d2d;
}
QMenu::item:selected {
background-color: #0d47a1;
}
"""
View File
+312
View File
@@ -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)
+99
View File
@@ -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)
+389
View File
@@ -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)
+118
View File
@@ -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)
+99
View File
@@ -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()