diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f040b5d6..08e1ef0b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,5 +1,5 @@ name: Lint, Typecheck and Test -on: [push, pull_request] +on: [pull_request] jobs: check: @@ -35,7 +35,15 @@ jobs: matrix: os: [ubuntu-22.04, macos-14, windows-2022] runs-on: ${{ matrix.os }} + env: + QT_QPA_PLATFORM: offscreen steps: + # PyQt6.QtGui import requires libEGL.so.1 (even with QT_QPA_PLATFORM=offscreen) + - name: Packages + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 - name: Checkout uses: actions/checkout@v3 with: diff --git a/.vscode/launch.json b/.vscode/launch.json index d7b73705a..c1a351708 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -44,6 +44,16 @@ "tests" ], "cwd": "${workspaceFolder}", + }, + { + "name": "Design", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/scripts/design.py", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}" + }, } ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 81fde0411..a7cc4c443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,31 +9,35 @@ image generation functionality via diffusion models. The plugin runs within Krita's embedded Python interpreter. It may not use 3rd party libraries except for Qt and the websockets library. -### Inference -* `api.py` has data structures for an inference request (`WorkflowInput`) - everything relevant to image generation MUST be contained here -* `comfy_client.py` is a HTTP/WebSocket client that connects to a ComfyUI server to fulfill requests -* `cloud_client.py` is a HTTP client that connects to an image generation service to fulfill requests -* `workflow.py` transforms `WorkflowInput` into ComfyUI workflows -* `server.py` contains an installer for a ComfyUI server that will run in the background alongside the plugin -* `resources.py` is a central location that enumerates supported AI models and extensions - -### UI -The UI is separated into distinct workspaces: -* "Generation" for launching asynchronous image jobs and viewing their results -* "Live" for automatically generating preview output after every change -* "Upscale" for diffusion-based super-resolution tasks -* "Custom/Graph" for importing and running custom user ComfyUI workflows -* "Animation" for batch-processing image frames - -Code is separated into: -* `ai_diffusion/model`: Model classes which hold observable UI state and implement actions -* `ai_diffusion/ui`: Qt widgets for the user interface -* Persistence layer which loads/stores state in files or Krita documents - * `persistence.py`, `settings.py`, `files.py`, ... - -### Image manipulation -Helpers and tools for Krita's objects (`document.py`, `layer.py`) and -general image algorithms (`image.py`, `resolution.py`). +### Architecture + +* `ai_diffusion/backend/` - code related to run AI models via ComfyUI + * `api.py` - serializable data structures for an inference request (`WorkflowInput`) - everything relevant to image generation MUST be contained here + * `comfy_client.py` - HTTP/WebSocket client that connects to a ComfyUI server to fulfill requests + * `cloud_client.py` - HTTP client that connects to an image generation service to fulfill requests + * `workflow.py` - transforms `WorkflowInput` into ComfyUI workflows + * `server.py` - installer for a ComfyUI server that will run in the background alongside the plugin + * `resources.py` - central location that enumerates supported AI models and extensions +* `ai_diffusion/ui/` - Qt widgets that make up the UI + * `generation.py` - workspace for launching asynchronous image jobs and viewing their results in a history list + * `live.py` - workspace for automatically generating preview output after every change + * `upscale.py` - workspace for diffusion-based super-resolution tasks + * `custom_workflow.py` - workspace for importing and running custom user ComfyUI workflows + * `animation.py` - for batch-processing image frames +* `ai_diffusion/model/` - classes which hold the observable app state and actions, often there is a 1:1 relationship between `model/` and `ui/` files + * `connection.py` - manages the backend clients, shared across all documents + * `model.py` - the document model, almost all state is kept per opened document + * `jobs.py` - queued and finished diffusion jobs + * `control.py` - state related to control layers (images used as input to ControlNet models) + * `region.py` - state related to regional prompts (images whose alpha coverage is used as attention masks for diffusion) +* `ai_diffusion/` - Krita wrapper classes, persistence layer, image manipulation, and general helpers and utils + * `document.py` - used to interact with Krita documents + * `persistence.py` - stores/loads document state to .kra files + * `settings.py` - stores plugin settings in settings.json + * `style.py` - stores `Style` objects, each references a diffusion model along with parameters, LoRA, and default prompts + * `files.py` - lists of files, both local filesystem or remote on a server + * `image.py` - `Image`, `Mask`, `Extent` and `Bounds` objects, image manipulation + * `text.py` - text prompt processing tools ## Commands @@ -56,6 +60,13 @@ Run tests with these priorities: 4. *only after changing cloud client* (includes inference): `pytest tests/test_workflow.py --cloud` 5. *only after changing installer* (slow tests): `pytest tests/test_server.py --test-install` +### UI Tests + +There are no dedicated tests for UI. The following command can be used as a quick check +that the UI code runs without errors: +``` +python scripts/design.py --exit +``` ## Code Guidelines diff --git a/ai_diffusion/__init__.py b/ai_diffusion/__init__.py index 76a9e3098..1d79a85ca 100644 --- a/ai_diffusion/__init__.py +++ b/ai_diffusion/__init__.py @@ -18,8 +18,8 @@ if not getattr(krita, "IS_MOCK", False): krita_ver = krita.Krita.instance().version() - if not krita_ver.startswith("5"): - raise ImportError(f"This Plugin is for Krita 5.x, but you are using Krita {krita_ver}.") + if not krita_ver.startswith("6"): + raise ImportError(f"This Plugin is for Krita 6.x, but you are using Krita {krita_ver}.") from .extension import AIToolsExtension as AIToolsExtension diff --git a/ai_diffusion/backend/client.py b/ai_diffusion/backend/client.py index 3ef4dd3ed..5926ab89c 100644 --- a/ai_diffusion/backend/client.py +++ b/ai_diffusion/backend/client.py @@ -9,7 +9,7 @@ from enum import Enum from typing import Any, Generic, NamedTuple, TypeVar -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal from ..files import FileFormat, FileLibrary from ..image import ImageCollection, Point diff --git a/ai_diffusion/backend/comfy_workflow.py b/ai_diffusion/backend/comfy_workflow.py index d580fb041..ad871c52c 100644 --- a/ai_diffusion/backend/comfy_workflow.py +++ b/ai_diffusion/backend/comfy_workflow.py @@ -269,7 +269,7 @@ def _add_image(self, image: Image): return id def _add_image_hashed(self, image: Image): - data = image.to_bytes() + data = image.to_bytes().data() hash = zlib.crc32(data) id = f"{hash:08x}" self.image_data[id] = data diff --git a/ai_diffusion/backend/network.py b/ai_diffusion/backend/network.py index f106392ec..3cc281d79 100644 --- a/ai_diffusion/backend/network.py +++ b/ai_diffusion/backend/network.py @@ -8,22 +8,27 @@ from pathlib import Path from typing import NamedTuple -from PyQt5.QtCore import QBuffer, QByteArray, QFile, QUrl -from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest, QSslError +from PyQt6.QtCore import QBuffer, QByteArray, QFile, QUrl +from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest, QSslError from ..localization import translate as _ from ..util import client_logger as log class NetworkError(Exception): - code: int + code: int | QNetworkReply.NetworkError message: str url: str status: int | None = None data: dict | None = None def __init__( - self, code: int, msg: str, url: str, status: int | None = None, data: dict | None = None + self, + code: int | QNetworkReply.NetworkError, + msg: str, + url: str, + status: int | None = None, + data: dict | None = None, ): self.code = code self.message = msg @@ -37,7 +42,7 @@ def __str__(self): @staticmethod def from_reply(reply: QNetworkReply): - code: QNetworkReply.NetworkError = reply.error() # type: ignore (bug in PyQt5-stubs) + code = reply.error() url = reply.url().toString() status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) if reply.isReadable(): @@ -96,7 +101,10 @@ def set_auth(self, bearer: str): def _prepare_request(self, url: str, timeout: float | None = None, bearer: str | None = None): request = QNetworkRequest(QUrl(url)) - request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) + request.setAttribute( + QNetworkRequest.Attribute.RedirectPolicyAttribute, + QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy, + ) bearer_token = bearer or self._bearer_token if bearer_token: request.setRawHeader(b"Authorization", f"Bearer {bearer_token}".encode()) @@ -110,7 +118,7 @@ def http( self, method, url: str, - data: dict | QByteArray | None = None, + data: dict | QByteArray | bytes | None = None, timeout: float | None = None, bearer: str | None = None, ): @@ -158,7 +166,10 @@ async def upload(self, url: str, data: QByteArray | bytes, sha256: str | None = assert isinstance(data, QByteArray) request = QNetworkRequest(QUrl(url)) - request.setAttribute(QNetworkRequest.Attribute.FollowRedirectsAttribute, True) + request.setAttribute( + QNetworkRequest.Attribute.RedirectPolicyAttribute, + QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy, + ) if sha256: request.setRawHeader(b"x-amz-checksum-sha256", sha256.encode("utf-8")) request.setHeader( @@ -192,7 +203,7 @@ def download(self, url: str, timeout: float | None = None): buffer.open(QBuffer.OpenModeFlag.WriteOnly) def write(bytes_received, bytes_total): - buffer.write(reply.readAll()) + buffer.write(reply.readAll().data()) future = asyncio.get_running_loop().create_future() tracker = Request(url, future, buffer) @@ -212,14 +223,14 @@ def _upload_progress(self, bytes_sent: int, bytes_total: int): def _finished(self, reply: QNetworkReply): future = None try: - code = reply.error() # type: ignore (bug in PyQt5-stubs) + code = reply.error() tracker = self._requests[reply] future = tracker.future if future.cancelled(): return # operation was cancelled, discard result if code == QNetworkReply.NetworkError.NoError: if tracker.buffer is not None: - tracker.buffer.write(reply.readAll()) + tracker.buffer.write(reply.readAll().data()) future.set_result(tracker.buffer.data()) else: content_type = reply.header(QNetworkRequest.KnownHeaders.ContentTypeHeader) @@ -297,13 +308,17 @@ def _write_file_chunks(file: QFile, reply: QNetworkReply): async def _try_download(network: QNetworkAccessManager, url: str, path: Path): out_file = QFile(str(path) + ".part") - if not out_file.open(QFile.ReadWrite | QFile.Append): # type: ignore + rwa = QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.WriteOnly | QFile.OpenModeFlag.Append + if not out_file.open(rwa): raise RuntimeError( _("Error during download: could not open {path} for writing", path=out_file.fileName()) ) request = QNetworkRequest(QUrl(_map_host(url))) - request.setAttribute(QNetworkRequest.FollowRedirectsAttribute, True) + request.setAttribute( + QNetworkRequest.Attribute.RedirectPolicyAttribute, + QNetworkRequest.RedirectPolicy.NoLessSafeRedirectPolicy, + ) if out_file.size() > 0: log.info(f"Found {path}.part, resuming download from {out_file.size()} bytes") request.setRawHeader(b"Range", f"bytes={out_file.size()}-".encode()) @@ -325,9 +340,9 @@ def handle_finished(): out_file.close() if finished_future.cancelled(): return # operation was cancelled, discard result - if reply.error() == QNetworkReply.NetworkError.NoError: # type: ignore (bug in PyQt5-stubs) + if reply.error() == QNetworkReply.NetworkError.NoError: finished_future.set_result(path) - elif reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) == 416: + elif reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) == 416: # 416 = Range Not Satisfiable finished_future.set_exception(NetworkError(416, "Resume not supported", url)) else: diff --git a/ai_diffusion/backend/server.py b/ai_diffusion/backend/server.py index 254f0d63f..02597530c 100644 --- a/ai_diffusion/backend/server.py +++ b/ai_diffusion/backend/server.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import NamedTuple -from PyQt5.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkAccessManager from .. import eventloop from ..localization import translate as _ diff --git a/ai_diffusion/document.py b/ai_diffusion/document.py index 96770ba14..29f2cf684 100644 --- a/ai_diffusion/document.py +++ b/ai_diffusion/document.py @@ -7,7 +7,7 @@ import krita from krita import Krita -from PyQt5.QtCore import QByteArray, QObject, QTimer, pyqtSignal +from PyQt6.QtCore import QByteArray, QObject, QTimer, QUuid, pyqtSignal from .image import Bounds, Extent, Image, Mask from .layer import Layer, LayerManager, LayerType @@ -342,7 +342,7 @@ def _selection_is_entire_document(selection: krita.Selection, extent: Extent): class PoseLayers: def __init__(self): - self._layers: dict[str, Pose] = {} + self._layers: dict[QUuid, Pose] = {} self._timer = QTimer() self._timer.setInterval(500) self._timer.timeout.connect(self.update) diff --git a/ai_diffusion/eventloop.py b/ai_diffusion/eventloop.py index 4b3b9da29..08e822c05 100644 --- a/ai_diffusion/eventloop.py +++ b/ai_diffusion/eventloop.py @@ -1,7 +1,7 @@ import asyncio from collections.abc import Callable -from PyQt5.QtCore import QTimer +from PyQt6.QtCore import QTimer _loop = asyncio.new_event_loop() _timer = QTimer() diff --git a/ai_diffusion/extension.py b/ai_diffusion/extension.py index af0e232f0..7daa41fe5 100644 --- a/ai_diffusion/extension.py +++ b/ai_diffusion/extension.py @@ -3,7 +3,7 @@ from pathlib import Path from krita import DockWidgetFactory, DockWidgetFactoryBase, Extension, Krita, Window # type: ignore -from PyQt5.QtWidgets import QAction +from PyQt6.QtGui import QAction from . import __version__, eventloop from .model.model import Workspace @@ -88,5 +88,9 @@ def createActions(self, window): Krita.instance().addExtension(AIToolsExtension(Krita.instance())) Krita.instance().addDockWidgetFactory( - DockWidgetFactory("imageDiffusion", DockWidgetFactoryBase.DockRight, ImageDiffusionWidget) # type: ignore + DockWidgetFactory( + "imageDiffusion", + DockWidgetFactoryBase.DockPosition.DockRight, # type: ignore + ImageDiffusionWidget, + ) ) diff --git a/ai_diffusion/files.py b/ai_diffusion/files.py index 67a51162f..f02fc3d8d 100644 --- a/ai_diffusion/files.py +++ b/ai_diffusion/files.py @@ -7,8 +7,8 @@ from pathlib import Path from typing import Any, NamedTuple, cast -from PyQt5.QtCore import QAbstractListModel, QModelIndex, QSortFilterProxyModel, Qt -from PyQt5.QtGui import QIcon +from PyQt6.QtCore import QAbstractListModel, QModelIndex, QSortFilterProxyModel, Qt +from PyQt6.QtGui import QIcon from .util import client_logger as log from .util import encode_json, read_json_with_comments, user_data_dir diff --git a/ai_diffusion/image.py b/ai_diffusion/image.py index 12f896656..857eb6564 100644 --- a/ai_diffusion/image.py +++ b/ai_diffusion/image.py @@ -8,8 +8,8 @@ from pathlib import Path from typing import NamedTuple, SupportsIndex -from PyQt5.QtCore import QBuffer, QByteArray, QFile, QIODevice, QRect, QSize, Qt -from PyQt5.QtGui import ( +from PyQt6.QtCore import QBuffer, QByteArray, QFile, QIODevice, QRect, QSize, Qt +from PyQt6.QtGui import ( QColorSpace, QIcon, QImage, @@ -291,10 +291,16 @@ def extent_equal(a: QImage, b: QImage): return a.width() == b.width() and a.height() == b.height() -_qt_supports_webp = None +_qt_supports_webp: bool | None = None -def qt_supports_webp(): +def qt_supports_webp(extent: Extent | None = None): + # Qt6's VP8L encoder produces a degenerate 36-byte file for images smaller than + # this threshold that its own decoder cannot read back. 64x64 is safely above it. + if extent is not None and (extent.width < 64 or extent.height < 64): + return False + + # Check if the qt6-image-formats package is installed, which adds WEBP support global _qt_supports_webp if _qt_supports_webp is None: _qt_supports_webp = QByteArray(b"webp") in QImageWriter.supportedImageFormats() @@ -549,11 +555,11 @@ def data(self): return buffer else: ptr = ensure(self._qimage.constBits(), "Accessing data of invalid image") - return QByteArray(ptr.asstring(self._qimage.byteCount())) + return QByteArray(ptr.asstring(self._qimage.sizeInBytes())) @property def size(self): # in bytes - return self._qimage.byteCount() + return self._qimage.sizeInBytes() def to_array(self): import numpy as np @@ -571,7 +577,7 @@ def write( self, buffer: QIODevice, format=ImageFileFormat.png, override_quality: int | None = None ): # Compression takes time for large images and blocks the UI, might be worth to thread. - if not qt_supports_webp(): + if not qt_supports_webp(self.extent): format = format.no_webp_fallback format_str = format.extension quality = override_quality if override_quality is not None else format.quality @@ -582,7 +588,7 @@ def write( info = f"[{self.width}x{self.height} format={self._qimage.format()}] -> {format_str}@{quality}" if is_linux and format_str == "webp": log.warning( - "To enable support for writing webp images, you may need to install the 'qt5-imageformats' package." + "To enable support for writing webp images, you may need to install the 'qt6-imageformats' package." ) global _qt_supports_webp _qt_supports_webp = False @@ -666,7 +672,7 @@ def save( def save_png_with_metadata( self, filepath: str | Path, metadata_text: str, format: ImageFileFormat | None = None ): - png_bytes = bytes(self.to_bytes(format or ImageFileFormat.png)) + png_bytes = self.to_bytes(format or ImageFileFormat.png).data() self.save_png_w_itxt(filepath, png_bytes, "parameters", metadata_text) def debug_save(self, name): @@ -856,7 +862,7 @@ def to_array(self): def to_image(self, extent: Extent | None = None): if extent is None: return Image(self.image) - img = QImage(extent.width, extent.height, QImage.Format_Grayscale8) + img = QImage(extent.width, extent.height, QImage.Format.Format_Grayscale8) img.fill(0) painter = QPainter(img) painter.drawImage(self.bounds.x, self.bounds.y, self.image) diff --git a/ai_diffusion/layer.py b/ai_diffusion/layer.py index c46789324..fcdeb2650 100644 --- a/ai_diffusion/layer.py +++ b/ai_diffusion/layer.py @@ -5,8 +5,8 @@ from typing import ClassVar import krita -from PyQt5.QtCore import QByteArray, QObject, QTimer, QUuid, pyqtSignal -from PyQt5.QtGui import QImage +from PyQt6.QtCore import QByteArray, QObject, QTimer, QUuid, pyqtSignal +from PyQt6.QtGui import QImage from . import eventloop from .image import BlendMode, Bounds, Extent, Image, ImageCollection diff --git a/ai_diffusion/model/connection.py b/ai_diffusion/model/connection.py index 133387eae..1571ff2d9 100644 --- a/ai_diffusion/model/connection.py +++ b/ai_diffusion/model/connection.py @@ -4,8 +4,8 @@ from collections.abc import Iterable from enum import Enum -from PyQt5.QtCore import QObject, QUrl, pyqtSignal -from PyQt5.QtGui import QDesktopServices +from PyQt6.QtCore import QObject, QUrl, pyqtSignal +from PyQt6.QtGui import QDesktopServices from .. import eventloop, util from ..backend.client import ( diff --git a/ai_diffusion/model/control.py b/ai_diffusion/model/control.py index 961be2cd9..1d524bc77 100644 --- a/ai_diffusion/model/control.py +++ b/ai_diffusion/model/control.py @@ -2,9 +2,9 @@ import json from pathlib import Path -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple -from PyQt5.QtCore import QObject, Qt, QUuid, pyqtSignal +from PyQt6.QtCore import QObject, Qt, QUuid, pyqtSignal from .. import util from ..backend import resources @@ -15,9 +15,12 @@ from ..localization import translate as _ from ..util import PluginError from ..util import client_logger as log -from . import jobs, model +from . import jobs from .properties import ObservableProperties, Property +if TYPE_CHECKING: + from .model import DocumentModel + class ControlLayer(QObject, ObservableProperties): max_preset_value = 4 @@ -53,7 +56,7 @@ class ControlLayer(QObject, ObservableProperties): error_text_changed = pyqtSignal(str) modified = pyqtSignal(QObject, str) - def __init__(self, model: model.DocumentModel, mode: ControlMode, layer_id: QUuid, index: int): + def __init__(self, model: DocumentModel, mode: ControlMode, layer_id: QUuid, index: int): from .root import root super().__init__() @@ -220,14 +223,11 @@ class ControlLayerList(QObject): added = pyqtSignal(ControlLayer) removed = pyqtSignal(ControlLayer) - _model: model.DocumentModel - _layers: list[ControlLayer] - _last_mode = ControlMode.scribble - - def __init__(self, model: model.DocumentModel): + def __init__(self, model: DocumentModel): super().__init__() self._model = model - self._layers = [] + self._layers: list[ControlLayer] = [] + self._last_mode = ControlMode.scribble self._model.layers.removed.connect(self._remove_layer) def add(self): diff --git a/ai_diffusion/model/custom_workflow.py b/ai_diffusion/model/custom_workflow.py index 538a24bb0..d2f95fa95 100644 --- a/ai_diffusion/model/custom_workflow.py +++ b/ai_diffusion/model/custom_workflow.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, NamedTuple -from PyQt5.QtCore import ( +from PyQt6.QtCore import ( QAbstractListModel, QMetaObject, QModelIndex, diff --git a/ai_diffusion/model/jobs.py b/ai_diffusion/model/jobs.py index a88e25cfa..0bd7d6160 100644 --- a/ai_diffusion/model/jobs.py +++ b/ai_diffusion/model/jobs.py @@ -6,7 +6,7 @@ from enum import Enum, Flag from typing import TYPE_CHECKING, Any, NamedTuple -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal from ..backend.api import InpaintMode from ..image import Bounds, ImageCollection diff --git a/ai_diffusion/model/model.py b/ai_diffusion/model/model.py index 73cf02293..653b89e57 100644 --- a/ai_diffusion/model/model.py +++ b/ai_diffusion/model/model.py @@ -13,8 +13,8 @@ from tempfile import TemporaryDirectory from typing import Any, NamedTuple -from PyQt5.QtCore import QMetaObject, QObject, Qt, QUuid, pyqtSignal -from PyQt5.QtGui import QBrush, QColor, QPainter +from PyQt6.QtCore import QMetaObject, QObject, Qt, QUuid, pyqtSignal +from PyQt6.QtGui import QBrush, QColor, QPainter from .. import eventloop, util from ..backend import resolution, workflow diff --git a/ai_diffusion/model/properties.py b/ai_diffusion/model/properties.py index 28ff9e175..dee3edbd0 100644 --- a/ai_diffusion/model/properties.py +++ b/ai_diffusion/model/properties.py @@ -3,8 +3,8 @@ from enum import Enum from typing import Any, Generic, NamedTuple, TypeVar -from PyQt5.QtCore import QMetaObject, QObject, QUuid, pyqtBoundSignal -from PyQt5.QtWidgets import QComboBox +from PyQt6.QtCore import QMetaObject, QObject, QUuid, pyqtBoundSignal +from PyQt6.QtWidgets import QComboBox T = TypeVar("T") diff --git a/ai_diffusion/model/region.py b/ai_diffusion/model/region.py index 24884ec89..8b3b2e822 100644 --- a/ai_diffusion/model/region.py +++ b/ai_diffusion/model/region.py @@ -2,7 +2,7 @@ from enum import Enum -from PyQt5.QtCore import QMetaObject, QObject, QUuid, pyqtSignal +from PyQt6.QtCore import QMetaObject, QObject, QUuid, pyqtSignal from .. import eventloop from ..backend import workflow diff --git a/ai_diffusion/model/root.py b/ai_diffusion/model/root.py index 6eca0c3cd..b0c3f75d9 100644 --- a/ai_diffusion/model/root.py +++ b/ai_diffusion/model/root.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from pathlib import Path -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal from .. import platform_tools, util from ..backend.client import ClientMessage diff --git a/ai_diffusion/model/updates.py b/ai_diffusion/model/updates.py index 080b32b8d..1365da34a 100644 --- a/ai_diffusion/model/updates.py +++ b/ai_diffusion/model/updates.py @@ -6,7 +6,7 @@ from tempfile import TemporaryDirectory from typing import NamedTuple -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal from .. import __version__, eventloop from ..backend.network import RequestManager diff --git a/ai_diffusion/persistence.py b/ai_diffusion/persistence.py index 0535255a2..523354f87 100644 --- a/ai_diffusion/persistence.py +++ b/ai_diffusion/persistence.py @@ -7,9 +7,9 @@ from time import time from typing import Any -from PyQt5.QtCore import QByteArray, QObject -from PyQt5.QtGui import QImageReader -from PyQt5.QtWidgets import QMessageBox +from PyQt6.QtCore import QByteArray, QObject +from PyQt6.QtGui import QImageReader +from PyQt6.QtWidgets import QMessageBox from . import eventloop from .backend.api import FillMode, InpaintMode diff --git a/ai_diffusion/pose.py b/ai_diffusion/pose.py index e57070453..069149f6d 100644 --- a/ai_diffusion/pose.py +++ b/ai_diffusion/pose.py @@ -2,7 +2,7 @@ from functools import reduce from typing import NamedTuple -from PyQt5.QtCore import QPointF +from PyQt6.QtCore import QPointF from .image import Extent from .util import batched diff --git a/ai_diffusion/settings.py b/ai_diffusion/settings.py index 694e668bd..4d7f200ec 100644 --- a/ai_diffusion/settings.py +++ b/ai_diffusion/settings.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any, ClassVar, NamedTuple -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal from .localization import translate as _ from .platform_tools import is_macos, is_windows diff --git a/ai_diffusion/style.py b/ai_diffusion/style.py index 0d645ac28..7bc418898 100644 --- a/ai_diffusion/style.py +++ b/ai_diffusion/style.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Any, NamedTuple -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal from .backend.api import CheckpointInput, LoraInput from .backend.resources import Arch diff --git a/ai_diffusion/ui/animation.py b/ai_diffusion/ui/animation.py index 1c6e45064..fd9851edc 100644 --- a/ai_diffusion/ui/animation.py +++ b/ai_diffusion/ui/animation.py @@ -1,7 +1,7 @@ from __future__ import annotations -from PyQt5.QtCore import QMetaObject, Qt -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QMetaObject, Qt +from PyQt6.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, @@ -67,12 +67,12 @@ def __init__(self): prompt_layout.addWidget(self.negative_textbox) layout.addLayout(prompt_layout) - self.strength_slider = StrengthWidget(parent=self) + self.strength_slider = StrengthWidget() self.add_control_button = create_wide_tool_button( "control-add", _("Add Control Layer"), self ) strength_layout = QHBoxLayout() - strength_layout.addWidget(self.strength_slider) + strength_layout.addWidget(self.strength_slider.widget()) strength_layout.addWidget(self.add_control_button) layout.addLayout(strength_layout) @@ -110,12 +110,12 @@ def __init__(self): self.target_layer = QComboBox(self) self.target_layer.setMinimumContentsLength(20) self.target_layer.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLength + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) layout.addWidget(self.target_layer) self.preview_area = QLabel(self) - self.preview_area.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.preview_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.preview_area.setAlignment( Qt.AlignmentFlag(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignLeft) ) diff --git a/ai_diffusion/ui/autocomplete.py b/ai_diffusion/ui/autocomplete.py index 6835fcf32..5309f436f 100644 --- a/ai_diffusion/ui/autocomplete.py +++ b/ai_diffusion/ui/autocomplete.py @@ -3,9 +3,9 @@ from enum import Enum from typing import ClassVar, cast -from PyQt5.QtCore import QAbstractProxyModel, QRect, QSize, QStringListModel, Qt -from PyQt5.QtGui import QColor, QFont, QFontMetrics, QPalette, QPen, QTextCursor -from PyQt5.QtWidgets import QApplication, QCompleter, QPlainTextEdit, QStyle, QStyledItemDelegate +from PyQt6.QtCore import QAbstractProxyModel, QRect, QSize, QStringListModel, Qt +from PyQt6.QtGui import QColor, QFont, QFontMetrics, QPalette, QPen, QTextCursor +from PyQt6.QtWidgets import QApplication, QCompleter, QPlainTextEdit, QStyle, QStyledItemDelegate from ..files import FileFilter from ..model.root import root @@ -65,7 +65,7 @@ def paint(self, painter, option, index): # Calculate rectangles rect = option.rect - meta_width = QFontMetrics(normal_font).width(tag_item.meta) + 10 + meta_width = QFontMetrics(normal_font).horizontalAdvance(tag_item.meta) + 10 meta_rect = QRect(rect.right() - meta_width, rect.top(), meta_width, rect.height()) # Draw the tag @@ -91,8 +91,8 @@ def sizeHint(self, option, index): small_font = QFont(option.font) small_font.setPointSize(normal_font.pointSize() - 2) - tag_width = QFontMetrics(normal_font).width(tag_item.tag) - meta_width = QFontMetrics(small_font).width(tag_item.meta) + tag_width = QFontMetrics(normal_font).horizontalAdvance(tag_item.tag) + meta_width = QFontMetrics(small_font).horizontalAdvance(tag_item.meta) total_width = tag_width + meta_width + 10 # Add some padding size = super().sizeHint(option, index) @@ -119,7 +119,7 @@ def _background_color(self, tag_type): # Get the default background color for dropdown items app = cast(QApplication, QApplication.instance()) - base_color = app.palette().color(QPalette.Base) + base_color = app.palette().color(QPalette.ColorRole.Base) # Blend the colors return self._blend_colors(base_color, tag_color, 0.2) diff --git a/ai_diffusion/ui/control.py b/ai_diffusion/ui/control.py index 7981714e5..24049dbc6 100644 --- a/ai_diffusion/ui/control.py +++ b/ai_diffusion/ui/control.py @@ -1,8 +1,8 @@ from __future__ import annotations -from PyQt5.QtCore import QMetaObject, Qt, pyqtSignal -from PyQt5.QtGui import QResizeEvent -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QMetaObject, Qt, pyqtSignal +from PyQt6.QtGui import QResizeEvent +from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QFrame, @@ -47,7 +47,7 @@ def __init__( self.layer_select = QComboBox(self) self.layer_select.setMinimumContentsLength(20) self.layer_select.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLength + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) self._update_layers() root.active_model.layers.changed.connect(self._update_layers) diff --git a/ai_diffusion/ui/custom_workflow.py b/ai_diffusion/ui/custom_workflow.py index f3dbf9adb..31d06397b 100644 --- a/ai_diffusion/ui/custom_workflow.py +++ b/ai_diffusion/ui/custom_workflow.py @@ -5,10 +5,9 @@ from typing import Any from krita import Krita -from PyQt5.QtCore import QMetaObject, QPoint, QSize, Qt, QUrl, QUuid, pyqtSignal -from PyQt5.QtGui import QDesktopServices, QFontMetrics, QIcon, QPalette -from PyQt5.QtWidgets import ( - QAction, +from PyQt6.QtCore import QMetaObject, QPoint, QSize, Qt, QUrl, QUuid, pyqtSignal +from PyQt6.QtGui import QAction, QDesktopServices, QFontMetrics, QIcon, QPalette +from PyQt6.QtWidgets import ( QComboBox, QDoubleSpinBox, QFileDialog, @@ -67,7 +66,7 @@ def __init__(self, filter: str | None, model: DocumentModel, parent: QWidget | N self.setContentsMargins(0, 0, 0, 0) self.setMinimumContentsLength(20) - self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLength) + self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) self.currentIndexChanged.connect(lambda _: self.value_changed.emit()) self._update() @@ -260,7 +259,9 @@ def __init__(self, param: CustomParam, parent: QWidget | None = None): fm = QFontMetrics(self.font()) self._label = QLabel(self) - self._label.setMinimumWidth(max(fm.width(self._true_text), fm.width(self._false_text)) + 4) + self._label.setMinimumWidth( + max(fm.horizontalAdvance(self._true_text), fm.horizontalAdvance(self._false_text)) + 4 + ) self._widget = SwitchWidget(parent) self._widget.toggled.connect(self._notify) layout.addWidget(self._widget) @@ -364,7 +365,7 @@ def __init__(self, param: CustomParam, parent: QWidget | None = None): super().__init__(parent) self.param = param self.setMinimumContentsLength(20) - self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLength) + self.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon) if param.choices: self.addItems(param.choices) @@ -463,10 +464,13 @@ def _create_reset_button(parent: QWidget, text: str): return button +ParamWidgetList = list[QLabel | CustomParamWidget] + + class GroupHeader(QWidget): def __init__(self, text: str, parent: QWidget | None = None): super().__init__(parent) - self._group_widgets: list[CustomParamWidget] = [] + self._group_widgets: ParamWidgetList = [] self._expander = ExpanderButton(text, self) self._expander.toggled.connect(self._show_group) @@ -480,7 +484,7 @@ def __init__(self, text: str, parent: QWidget | None = None): layout.addWidget(self._expander, stretch=1, alignment=Qt.AlignmentFlag.AlignLeft) layout.addWidget(self._reset_button, alignment=Qt.AlignmentFlag.AlignRight) - def set_group_widgets(self, widgets: list[CustomParamWidget], show_group: bool): + def set_group_widgets(self, widgets: ParamWidgetList, show_group: bool): self._group_widgets = widgets self._expander.setChecked(show_group) self._show_group(show_group) @@ -515,7 +519,7 @@ def __init__( self.setLayout(layout) params = sorted(params) - current_group: tuple[str, GroupHeader | None, list[CustomParamWidget]] = ("", None, []) + current_group: tuple[str, GroupHeader | None, ParamWidgetList] = ("", None, []) header = QLabel(_("Workflow Parameters"), self) reset_text = _("Reset all parameters to their default values") @@ -551,7 +555,7 @@ def __init__( def _notify(self): self.value_changed.emit() - def _create_group(self, expander: GroupHeader | None, widgets: list[CustomParamWidget]): + def _create_group(self, expander: GroupHeader | None, widgets: ParamWidgetList): display_height = sum(w.sizeHint().height() for w in widgets if not isinstance(w, QLabel)) display_height += 2 * len(widgets) # spacing if expander is not None: @@ -911,7 +915,7 @@ def _show_generate_menu(self): width = self._generate_button.width() + self._mode_button.width() pos = QPoint(0, self._generate_button.height()) self._generate_menu.setFixedWidth(width) - self._generate_menu.exec_(self._generate_button.mapToGlobal(pos)) + self._generate_menu.exec(self._generate_button.mapToGlobal(pos)) def _update_ui(self): is_live_mode = self.model.custom.mode is CustomGenerationMode.live @@ -1020,8 +1024,7 @@ def _delete_workflow(self): self, _("Delete Workflow"), _("Are you sure you want to delete the current workflow?") + f"\n{filepath}", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if q == QMessageBox.StandardButton.Yes: self.model.custom.remove_workflow() @@ -1071,8 +1074,7 @@ def _accept_name(self, *args): name=name ) + details, - QMessageBox.Yes | QMessageBox.No, - QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if q == QMessageBox.StandardButton.Yes: overwrite = True diff --git a/ai_diffusion/ui/diffusion.py b/ai_diffusion/ui/diffusion.py index 77e023896..54ff3bb3d 100644 --- a/ai_diffusion/ui/diffusion.py +++ b/ai_diffusion/ui/diffusion.py @@ -4,8 +4,8 @@ import krita from krita import DockWidget, Krita -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtWidgets import ( QCheckBox, QHBoxLayout, QLabel, diff --git a/ai_diffusion/ui/generation.py b/ai_diffusion/ui/generation.py index 814ace6ca..fdc32fef6 100644 --- a/ai_diffusion/ui/generation.py +++ b/ai_diffusion/ui/generation.py @@ -4,7 +4,7 @@ from textwrap import wrap as wrap_text from typing import ClassVar, cast -from PyQt5.QtCore import ( +from PyQt6.QtCore import ( QEvent, QItemSelectionModel, QMetaObject, @@ -15,7 +15,8 @@ QUuid, pyqtSignal, ) -from PyQt5.QtGui import ( +from PyQt6.QtGui import ( + QAction, QColor, QGuiApplication, QIcon, @@ -24,10 +25,10 @@ QMouseEvent, QPalette, ) -from PyQt5.QtWidgets import ( - QAction, +from PyQt6.QtWidgets import ( QCheckBox, QComboBox, + QFrame, QHBoxLayout, QListView, QListWidget, @@ -98,13 +99,13 @@ def __init__(self, parent: QWidget | None): self._model = root.active_model self._connections = [] - self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self.setResizeMode(QListView.Adjust) + self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.setResizeMode(QListView.ResizeMode.Adjust) self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self.setFlow(QListView.LeftToRight) - self.setViewMode(QListWidget.IconMode) + self.setFlow(QListView.Flow.LeftToRight) + self.setViewMode(QListView.ViewMode.IconMode) self.setIconSize(theme.screen_scale(self, QSize(self._thumb_size, self._thumb_size))) - self.setFrameStyle(QListWidget.NoFrame) + self.setFrameStyle(QFrame.Shape.NoFrame) self.setStyleSheet(self._list_css) self.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) self.setDragEnabled(False) @@ -312,7 +313,7 @@ def update_apply_button(self): rect = self.visualItemRect(selected[0]) font = self._apply_button.fontMetrics() context_visible = rect.width() >= 0.6 * self.iconSize().width() - apply_text_visible = font.width(_("Apply")) < 0.35 * rect.width() + apply_text_visible = font.horizontalAdvance(_("Apply")) < 0.35 * rect.width() apply_pos = QPoint(rect.left() + 3, rect.bottom() - self._apply_button.height() - 2) if context_visible: cw = self._context_button.width() @@ -509,16 +510,16 @@ def _save_image(self): def _discard_image(self, confirm=True): confirm = confirm and settings.confirm_discard_image - reply = QMessageBox.Yes + reply = QMessageBox.StandardButton.Yes if confirm: reply = QMessageBox.warning( self, _("Discard Image"), _("Are you sure you want to discard the selected images?"), - QMessageBox.Yes | QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes, ) - if reply == QMessageBox.Yes: + if reply == QMessageBox.StandardButton.Yes: items = self.selectedItems() next_item = self.row(items[0]) if len(items) > 0 else -1 for item in items: @@ -532,10 +533,10 @@ def _clear_all(self): self, _("Clear History"), _("Are you sure you want to discard all generated images?"), - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, ) - if reply == QMessageBox.Yes: + if reply == QMessageBox.StandardButton.Yes: self._model.jobs.clear() self.clear() self._model.hide_preview(delete_layer=True) @@ -622,7 +623,7 @@ def ctx_icon(name): ) self.context_combo.setMinimumContentsLength(20) self.context_combo.setSizeAdjustPolicy( - QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLength + QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon ) self.context_combo.currentIndexChanged.connect(self.set_context) @@ -756,7 +757,7 @@ def __init__(self): self.region_prompt = RegionPromptWidget(self) layout.addWidget(self.region_prompt) - self.strength_slider = StrengthWidget(parent=self) + self.strength_slider = StrengthWidget() self.layer_count_widget = LayerCountWidget(self) self.layer_count_widget.setVisible(False) self.add_region_button = create_wide_tool_button("region-add", _("Add Region"), self) @@ -764,7 +765,7 @@ def __init__(self): "control-add", _("Add Control Layer"), self ) strength_layout = QHBoxLayout() - strength_layout.addWidget(self.strength_slider) + strength_layout.addWidget(self.strength_slider.widget()) strength_layout.addWidget(self.layer_count_widget) strength_layout.addWidget(self.add_control_button) strength_layout.addWidget(self.add_region_button) @@ -973,7 +974,7 @@ def show_inpaint_menu(self): menu.actions()[1].setEnabled(self.model.can_edit) menu.setFixedWidth(width) - menu.exec_(self.generate_button.mapToGlobal(pos)) + menu.exec(self.generate_button.mapToGlobal(pos)) def change_inpaint_mode(self, mode: InpaintMode, is_edit: bool | None): self.model.inpaint.mode = mode diff --git a/ai_diffusion/ui/interval_slider.py b/ai_diffusion/ui/interval_slider.py index 1e843950d..9801d396a 100644 --- a/ai_diffusion/ui/interval_slider.py +++ b/ai_diffusion/ui/interval_slider.py @@ -13,10 +13,10 @@ # [GPL-3.0]: https://www.gnu.org/licenses/gpl-3.0.en.html -from PyQt5.QtCore import QRect, QSize, Qt -from PyQt5.QtCore import pyqtSignal as Signal -from PyQt5.QtGui import QBrush, QMouseEvent, QPainter, QPalette -from PyQt5.QtWidgets import QSizePolicy, QSlider, QStyle, QStyleOptionSlider, QWidget +from PyQt6.QtCore import QRect, QSize, Qt +from PyQt6.QtCore import pyqtSignal as Signal +from PyQt6.QtGui import QBrush, QMouseEvent, QPainter, QPalette +from PyQt6.QtWidgets import QSizePolicy, QSlider, QStyle, QStyleOptionSlider, QWidget # Based on idea and in part the code from # https://stackoverflow.com/questions/47342158/porting-range-slider-widget-to-pyqt5 @@ -63,7 +63,11 @@ class IntervalSlider(QWidget): def __init__(self, low=1, high=8, minimum=0, maximum=10, parent: QWidget | None = None, **args): super().__init__(parent) self.setSizePolicy( - QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed, QSizePolicy.Slider) + QSizePolicy( + QSizePolicy.Policy.Expanding, + QSizePolicy.Policy.Fixed, + QSizePolicy.ControlType.Slider, + ) ) self._dragged = self.NoHandle @@ -234,7 +238,7 @@ def paintEvent(self, a0) -> None: # Draw interval # Interval has an arbitrary width of 4; I don't know how to get the # actual groove thickness. - color = self.palette().color(QPalette.Highlight) + color = self.palette().color(QPalette.ColorRole.Highlight) painter.setBrush(QBrush(color)) painter.setPen(Qt.PenStyle.NoPen) @@ -270,7 +274,7 @@ def sizeHint(self) -> QSize: w = SliderLength h = style.pixelMetric(QStyle.PixelMetric.PM_SliderThickness, self.opt, self) - if self.opt.tickPosition != QSlider.NoTicks: + if self.opt.tickPosition != QSlider.TickPosition.NoTicks: h += TickSpace return style.sizeFromContents(QStyle.ContentsType.CT_Slider, self.opt, QSize(w, h)) diff --git a/ai_diffusion/ui/live.py b/ai_diffusion/ui/live.py index 90de77af2..3b59ca5ed 100644 --- a/ai_diffusion/ui/live.py +++ b/ai_diffusion/ui/live.py @@ -1,8 +1,8 @@ from __future__ import annotations -from PyQt5.QtCore import QMetaObject, QRectF, Qt, QTimer -from PyQt5.QtGui import QColor, QFont, QPainter, QPen -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QMetaObject, QRectF, Qt, QTimer +from PyQt6.QtGui import QColor, QFont, QPainter, QPen +from PyQt6.QtWidgets import ( QDoubleSpinBox, QHBoxLayout, QLabel, @@ -163,7 +163,7 @@ def __init__(self): controls_layout.addWidget(self.style_select) layout.addLayout(controls_layout) - self.strength_slider = StrengthWidget(parent=self) + self.strength_slider = StrengthWidget() self.seed_input = QDoubleSpinBox(self) self.seed_input.setDecimals(0) @@ -190,13 +190,13 @@ def __init__(self): self.edit_toggle.clicked.connect(self.toggle_edit) params_layout = QHBoxLayout() - params_layout.addWidget(self.strength_slider) - params_layout.addWidget(self.seed_input) - params_layout.addWidget(self.random_seed_button) + params_layout.addWidget(self.strength_slider.widget(), 2) params_layout.addWidget(self.edit_toggle) + params_layout.addWidget(self.seed_input, 1) + params_layout.addWidget(self.random_seed_button) layout.addLayout(params_layout) - self.control_list = ControlListWidget(self) + self.control_list = ControlListWidget(self._model.active_regions.control, self) self.add_control_button = create_wide_tool_button( "control-add", _("Add Control Layer"), self ) diff --git a/ai_diffusion/ui/region.py b/ai_diffusion/ui/region.py index 081b3e93c..a6b1b8ed3 100644 --- a/ai_diffusion/ui/region.py +++ b/ai_diffusion/ui/region.py @@ -3,8 +3,8 @@ from enum import Enum from functools import partial -from PyQt5.QtCore import QEvent, QMetaObject, QObject, QPoint, QSize, Qt, pyqtSignal -from PyQt5.QtGui import ( +from PyQt6.QtCore import QEvent, QMetaObject, QObject, QPoint, QSize, Qt, pyqtSignal +from PyQt6.QtGui import ( QFontMetrics, QGuiApplication, QIcon, @@ -14,7 +14,7 @@ QPixmap, QResizeEvent, ) -from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QToolButton, QVBoxLayout, QWidget +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QMenu, QToolButton, QVBoxLayout, QWidget from ..backend.client import Client from ..document import LayerType @@ -96,7 +96,7 @@ def __init__(self, root: RootRegion, parent: QWidget, header=PromptHeader.full): super().__init__(parent) self._root = root self._region: RootRegion | Region | None = root - self._bindings: list[QMetaObject.Connection] = [] + self._bindings: list[QMetaObject.Connection | Binding] = [] self._header_style = header self._translation_enabled = True self._is_slim = False @@ -340,7 +340,7 @@ def link(r: Region): action.triggered.connect(partial(link, region)) pos = self._link_region_button.rect().bottomLeft() - menu.exec_(self._link_region_button.mapToGlobal(pos)) + menu.exec(self._link_region_button.mapToGlobal(pos)) @property def is_slim(self): @@ -428,7 +428,7 @@ def _layout_language_button(self): pos = self.positive.geometry().bottomRight() if self.has_negative: pos = self.negative.geometry().bottomRight() - s = QSize(self.fontMetrics().width("EN"), self.fontMetrics().height()) + s = QSize(self.fontMetrics().horizontalAdvance("EN"), self.fontMetrics().height()) self._language_button.move(pos.x() - s.width() - 2, pos.y() - s.height() - 2) self._language_button.resize(s) diff --git a/ai_diffusion/ui/server.py b/ai_diffusion/ui/server.py index 261be638c..1575c1abb 100644 --- a/ai_diffusion/ui/server.py +++ b/ai_diffusion/ui/server.py @@ -7,9 +7,9 @@ from pathlib import Path from krita import Krita -from PyQt5.QtCore import Qt, QUrl, pyqtSignal -from PyQt5.QtGui import QDesktopServices -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt, QUrl, pyqtSignal +from PyQt6.QtGui import QDesktopServices +from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, @@ -136,6 +136,7 @@ def add_item(self, package: str | ModelResource | CustomNode): ) item.label.setContentsMargins(20, 0, 0, 0) if self.is_checkable: + assert isinstance(item.status, QCheckBox) item.status.setChecked(False) item.status.toggled.connect(self._handle_checkbox_toggle) self._layout.addWidget(item.label, self._layout.rowCount(), 0) @@ -176,6 +177,7 @@ def _update(self): else: item.status.setText(_("Workload not selected")) item.status.setStyleSheet(f"color:{grey}") + assert isinstance(item.status, QCheckBox) with SignalBlocker(item.status): item.status.setChecked( item.state in [PackageState.selected, PackageState.installed] @@ -266,6 +268,7 @@ def _update_status(self): def _handle_checkbox_toggle(self): for item in self._items: if item.state in [PackageState.available, PackageState.selected]: + assert isinstance(item.status, QCheckBox) item.state = ( PackageState.selected if item.status.isChecked() else PackageState.available ) @@ -881,7 +884,7 @@ def __init__(self, srv: Server, parent=None): self._manage_button = QToolButton(self) self._manage_button.setText(_("Manage")) - self._manage_button.setPopupMode(QToolButton.InstantPopup) + self._manage_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self._manage_button.setMinimumWidth(150) menu = QMenu(self) @@ -926,7 +929,7 @@ def __init__(self, srv: Server, parent=None): scroll = QScrollArea(tabs) scroll.setWidget(tab) scroll.setWidgetResizable(True) - scroll.setFrameStyle(QFrame.NoFrame) + scroll.setFrameStyle(QFrame.Shape.NoFrame) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) tabs.addTab(scroll, tab.title) @@ -954,7 +957,7 @@ def _select_location(self): path = Path(Settings._server_path.default) path.mkdir(parents=True, exist_ok=True) path = QFileDialog.getExistingDirectory( - self, _("Select Directory"), str(path), QFileDialog.ShowDirsOnly + self, _("Select Directory"), str(path), QFileDialog.Option.ShowDirsOnly ) if path: path = Path(path) @@ -989,7 +992,7 @@ def _check_cuda_support(self): self, _("No CUDA Devices Found"), question, - QMessageBox.Yes | QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) return answer == QMessageBox.StandardButton.Yes @@ -1138,16 +1141,16 @@ async def _verify_models(self): ]) msg_box = QMessageBox( - QMessageBox.Warning, + QMessageBox.Icon.Warning, _("Verification Failed"), _("The following files failed verification:") + f"\n\n{failed_files}\n\n" + _("Would you like to delete and re-download these files?"), - QMessageBox.Yes | QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self, ) - if msg_box.exec_() == QMessageBox.Yes: + if msg_box.exec() == QMessageBox.StandardButton.Yes: await self._server.fix_models(bad_models, self._handle_progress) except Exception as e: self.show_error(e) @@ -1162,16 +1165,16 @@ def reinstall(self): async def _reinstall(self): msg_box = QMessageBox( - QMessageBox.Question, + QMessageBox.Icon.Question, _("Confirm Reinstallation"), _( "This will reinstall the server components while keeping your downloaded models. Continue?" ), - QMessageBox.Yes | QMessageBox.No, + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, self, ) - if msg_box.exec_() != QMessageBox.Yes: + if msg_box.exec() != QMessageBox.StandardButton.Yes: return await self._prepare_for_install() @@ -1191,19 +1194,19 @@ def uninstall(self): async def _uninstall(self): msg_box = QMessageBox( - QMessageBox.Warning, + QMessageBox.Icon.Warning, _("Confirm Deletion"), _("WARNING: This will delete the entire server installation INCLUDING ALL MODELS!") + "\n\n" + _("This action cannot be undone.") + "\n\n" + _("Are you absolutely sure you want to continue?"), - QMessageBox.Cancel, + QMessageBox.StandardButton.Cancel, self, ) - msg_box.addButton(_("Delete"), QMessageBox.DestructiveRole) - msg_box.setDefaultButton(QMessageBox.Cancel) - if msg_box.exec_() != 0: # Destructive role returns 0 + msg_box.addButton(_("Delete"), QMessageBox.ButtonRole.DestructiveRole) + msg_box.setDefaultButton(QMessageBox.StandardButton.Cancel) + if msg_box.exec() != 0: # Destructive role returns 0 return await self._prepare_for_install() diff --git a/ai_diffusion/ui/settings.py b/ai_diffusion/ui/settings.py index 2d09aed58..3082a506b 100644 --- a/ai_diffusion/ui/settings.py +++ b/ai_diffusion/ui/settings.py @@ -1,8 +1,10 @@ from __future__ import annotations +from typing import cast + from krita import Krita -from PyQt5.QtCore import QMetaObject, QSize, Qt, QUrl, pyqtSignal -from PyQt5.QtGui import ( +from PyQt6.QtCore import QMetaObject, QSize, Qt, QUrl, pyqtSignal +from PyQt6.QtGui import ( QColor, QCursor, QDesktopServices, @@ -11,7 +13,7 @@ QGuiApplication, QPainter, ) -from PyQt5.QtWidgets import ( +from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QDialog, @@ -679,9 +681,9 @@ def __init__(self): super().__init__(_("Diffusion Settings")) S = Settings - self.add("selection_feather", SliderSetting(S._selection_feather, self, 0, 25, "{} %")) - self.add("selection_blend", SliderSetting(S._selection_blend, self, 0, 100, "{} px")) - self.add("selection_padding", SliderSetting(S._selection_padding, self, 0, 25, "{} %")) + self.add("selection_feather", SliderSetting(S._selection_feather, self, 0, 25, suffix="%")) + self.add("selection_blend", SliderSetting(S._selection_blend, self, 0, 100, suffix=" px")) + self.add("selection_padding", SliderSetting(S._selection_padding, self, 0, 25, suffix=" %")) self.add("color_match", SwitchSetting(S._color_match, parent=self)) self.add("nsfw_filter", ComboBoxSetting(S._nsfw_filter, parent=self)) @@ -724,13 +726,14 @@ def __init__(self): self.add("recent_styles_count", SpinBoxSetting(S._recent_styles_count, self, 0, 10)) self.add("tag_files", FileListSetting(S._tag_files, files=self._tag_files(), parent=self)) - self._layout.addWidget(self._widgets["tag_files"].list_widget) - self._widgets["tag_files"].add_button( + tag_files = cast(FileListSetting, self._widgets["tag_files"]) + self._layout.addWidget(tag_files.list_widget) + tag_files.add_button( Krita.instance().icon("reload-preset"), _("Look for new tag files"), self._update_tag_files, ) - self._widgets["tag_files"].add_button( + tag_files.add_button( Krita.instance().icon("document-open"), _("Open folder where custom tag files can be placed"), self._open_tag_folder, @@ -779,7 +782,8 @@ def _tag_files(self) -> list[str]: return list(files) def _update_tag_files(self): - self._widgets["tag_files"].reset_files(self._tag_files()) + tag_files = cast(FileListSetting, self._widgets["tag_files"]) + tag_files.reset_files(self._tag_files()) def _open_tag_folder(self): user_tag_folder = util.user_data_dir / "tags" @@ -787,7 +791,7 @@ def _open_tag_folder(self): QDesktopServices.openUrl(QUrl.fromLocalFile(str(user_tag_folder))) def update_translation(self, client: Client | None): - translation: ComboBoxSetting = self._widgets["prompt_translation"] + translation = cast(ComboBoxSetting, self._widgets["prompt_translation"]) languages = [("Disabled", "")] if client: languages += [(lang.name, lang.code) for lang in client.features.languages] @@ -874,7 +878,7 @@ def __init__(self): advanced_layout.addWidget(self._batch_size) self._resolution_multiplier = SliderSetting( - Settings._resolution_multiplier, self._advanced, 0.3, 1.5, "{:.1f}x" + Settings._resolution_multiplier, self._advanced, 0.3, 1.5, suffix="x", decimals=1 ) self._resolution_multiplier.value_changed.connect(self.write) advanced_layout.addWidget(self._resolution_multiplier) @@ -1126,7 +1130,7 @@ def _collect_diagnostics(self): layout.addWidget(text) window.setLayout(layout) window.resize(min(self.width(), 800), 640) - window.exec_() + window.exec() def _open_logs(self): QDesktopServices.openUrl(QUrl.fromLocalFile(str(util.log_dir))) @@ -1161,7 +1165,7 @@ def __init__(self, server: Server): self.setMinimumSize(QSize(960, 480)) if screen := QGuiApplication.screenAt(QCursor.pos()): size = screen.availableSize() - min_w = min(size.width(), QFontMetrics(self.font()).width("M") * 100) + min_w = min(size.width(), QFontMetrics(self.font()).horizontalAdvance("M") * 100) self.resize(QSize(min_w, int(size.height() * 0.8))) layout = QHBoxLayout() diff --git a/ai_diffusion/ui/settings_widgets.py b/ai_diffusion/ui/settings_widgets.py index 14d412ba0..faf10c78e 100644 --- a/ai_diffusion/ui/settings_widgets.py +++ b/ai_diffusion/ui/settings_widgets.py @@ -3,9 +3,10 @@ from enum import Enum from typing import Any -from PyQt5.QtCore import QAbstractItemModel, QSize, Qt, pyqtSignal -from PyQt5.QtGui import QFontMetrics, QIcon -from PyQt5.QtWidgets import ( +from krita import DoubleSliderSpinBox +from PyQt6.QtCore import QAbstractItemModel, QSize, Qt, pyqtSignal +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QFrame, @@ -14,7 +15,6 @@ QLineEdit, QScrollArea, QSizePolicy, - QSlider, QSpinBox, QToolButton, QVBoxLayout, @@ -63,48 +63,16 @@ def hide(self): self.setVisible(False) -class SettingWidget(QWidget): +class SettingWidgetBase(QWidget): value_changed = pyqtSignal() - def __init__(self, setting: Setting, parent=None): + def __init__(self, parent=None): super().__init__(parent) - self._key_label = QLabel(f"{setting.name}
{setting.desc}") - self._key_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - - self._checkbox: QCheckBox | None = None self._widget: QWidget | None = None - + self._checkbox: QCheckBox | None = None self._indent = 0 self._show_label = True - self._layout = QHBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) - self._layout.addWidget(self._key_label) - self._layout.addStretch(1) - self.setLayout(self._layout) - self._set_margins() - - def set_widget(self, widget: QWidget): - self._widget = widget - self._layout.addWidget(widget) - - def add_button(self, icon: QIcon, tooltip: str, handler): - button = QToolButton(self) - button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - button.setIcon(icon) - button.setToolTip(tooltip) - button.clicked.connect(handler) - self._layout.addWidget(button) - - def add_checkbox(self, text: str): - widget = self._widget - assert widget is not None - checkbox = self._checkbox = QCheckBox(text, self) - checkbox.toggled.connect(lambda v: widget.setEnabled(v)) - self._layout.removeWidget(self._widget) - self._layout.addWidget(checkbox) - self._layout.addWidget(self._widget) - return checkbox @property def visible(self): @@ -134,6 +102,57 @@ def indent(self, v: int): self._indent = v self._set_margins() + @property + def value(self): + raise NotImplementedError + + @value.setter + def value(self, v): + raise NotImplementedError + + def set_items(self, items: ComboItemList): + raise NotImplementedError + + def _set_margins(self): + self.setContentsMargins(self._indent * 16, 4 if self._show_label else 0, 0, 0) + + +class SettingWidget(SettingWidgetBase): + def __init__(self, setting: Setting, parent=None): + super().__init__(parent) + + self._key_label = QLabel(f"{setting.name}
{setting.desc}") + self._key_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) + + self._layout = QHBoxLayout() + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.addWidget(self._key_label) + self._layout.addStretch(1) + self.setLayout(self._layout) + self._set_margins() + + def set_widget(self, widget: QWidget): + self._widget = widget + self._layout.addWidget(widget) + + def add_button(self, icon: QIcon, tooltip: str, handler): + button = QToolButton(self) + button.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + button.setIcon(icon) + button.setToolTip(tooltip) + button.clicked.connect(handler) + self._layout.addWidget(button) + + def add_checkbox(self, text: str): + widget = self._widget + assert widget is not None + checkbox = self._checkbox = QCheckBox(text, self) + checkbox.toggled.connect(lambda v: widget.setEnabled(v)) + self._layout.removeWidget(self._widget) + self._layout.addWidget(checkbox) + self._layout.addWidget(self._widget) + return checkbox + @property def show_label(self): return self._show_label @@ -147,9 +166,6 @@ def show_label(self, v: bool): def _notify_value_changed(self): self.value_changed.emit() - def _set_margins(self): - self.setContentsMargins(self._indent * 16, 4 if self._show_label else 0, 0, 0) - class FileListSetting(SettingWidget): _files: list[str] @@ -240,46 +256,30 @@ def __init__( parent=None, minimum: float = 0, maximum: float = 100, - format="{}", + decimals: int = 0, + suffix="", ): super().__init__(setting, parent) self._format_string = format self._is_float = isinstance(setting.default, float) - slider_widget = QWidget(self) - slider_layout = QHBoxLayout() - slider_widget.setLayout(slider_layout) - self._slider = QSlider(Qt.Orientation.Horizontal, self) - self._slider.setMinimumWidth(200) - self._slider.setMaximumWidth(300) - self._slider.setMinimum(round(minimum * self.multiplier)) - self._slider.setMaximum(round(maximum * self.multiplier)) - self._slider.setSingleStep(1) - self._slider.valueChanged.connect(self._change_value) - self._label = QLabel(str(self._slider.value()), self) - fm = QFontMetrics(self._label.font()) - self._label.setMinimumWidth(fm.width("555 px")) - slider_layout.addWidget(self._slider) - slider_layout.addWidget(self._label) - self.set_widget(slider_widget) - - def _change_value(self, value: int): - self._label.setText(self._format_string.format(self.value)) - self.value_changed.emit() - - @property - def multiplier(self): - return 1 if not self._is_float else 10 + self.slider = DoubleSliderSpinBox() + self.slider.setRange(minimum, maximum, decimals) + self._spin = self.slider.widget() + self._spin.setSuffix(suffix) + self._spin.setSingleStep(10**-decimals) + self._spin.setMinimumWidth(235) + self._spin.setMaximumWidth(300) + self._spin.valueChanged.connect(self.value_changed) + self.set_widget(self._spin) @property def value(self): - x = self._slider.value() - return x if not self._is_float else x / self.multiplier + return self._spin.value() @value.setter def value(self, v: float): - x = int(v) if not self._is_float else round(v * self.multiplier) - self._slider.setValue(x) + self._spin.setValue(v) ComboItemList = list[str] | list[tuple[str, Any]] | list[tuple[str, Any, QIcon]] | type[Enum] @@ -365,7 +365,7 @@ def value(self, v): self._edit.setText(v) -class LineEditSetting(QWidget): +class LineEditSetting(SettingWidgetBase): value_changed = pyqtSignal() def __init__(self, setting: Setting, parent=None): @@ -446,7 +446,7 @@ def __init__(self, title: str): scroll = QScrollArea(self) scroll.setWidget(inner) scroll.setWidgetResizable(True) - scroll.setFrameStyle(QFrame.NoFrame) + scroll.setFrameStyle(QFrame.Shape.NoFrame) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) frame_layout.addWidget(scroll) diff --git a/ai_diffusion/ui/style.py b/ai_diffusion/ui/style.py index a1f02628c..f4d165d40 100644 --- a/ai_diffusion/ui/style.py +++ b/ai_diffusion/ui/style.py @@ -4,9 +4,9 @@ from typing import cast from krita import Krita -from PyQt5.QtCore import Qt, QUrl, pyqtSignal -from PyQt5.QtGui import QColor, QDesktopServices, QPalette -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import Qt, QUrl, pyqtSignal +from PyQt6.QtGui import QColor, QDesktopServices, QPalette +from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QCompleter, @@ -38,6 +38,7 @@ LineEditSetting, SettingsTab, SettingWidget, + SettingWidgetBase, SliderSetting, SpinBoxSetting, SwitchSetting, @@ -69,7 +70,7 @@ def __init__(self, loras: FileFilter, parent=None): small_font.setPointSize(small_font.pointSize() - 1) grey_text = self.palette() - grey_text.setColor(QPalette.ColorRole.Foreground, QColor(theme.grey)) + grey_text.setColor(QPalette.ColorRole.WindowText, QColor(theme.grey)) self._advanced_button = ExpanderButton(parent=self) self._advanced_button.toggled.connect(self._expand) @@ -409,7 +410,7 @@ def _add_item(self, lora: dict | File | None = None): self._item_list.addWidget(item) self.value_changed.emit() - def _remove_item(self, item: QWidget): + def _remove_item(self, item: LoraItem): # removing and creating items is slow, hiding allows reuse item.is_active = False self._item_list.removeWidget(item) @@ -514,10 +515,12 @@ def __init__(self, prefix: str, title: str, parent): info_layout.addStretch() info_layout.addWidget(self._user_presets_link) - self._steps = SliderSetting(StyleSettings.sampler_steps, self, 1, 100) + self._steps = SliderSetting(StyleSettings.sampler_steps, self, 1, 100, suffix=" steps") + self._steps.slider.setSoftMaximum(50) self._steps.value_changed.connect(self.notify_changed) - self._cfg = SliderSetting(StyleSettings.cfg_scale, self, 1.0, 20.0) + self._cfg = SliderSetting(StyleSettings.cfg_scale, self, 1.0, 24.0, decimals=1) + self._cfg.slider.setSoftMaximum(12.0) self._cfg.value_changed.connect(self.notify_changed) extended_layout = QVBoxLayout() @@ -578,10 +581,6 @@ def write(self, style: Style): class StylePresets(SettingsTab): - _checkpoint_advanced_widgets: list[SettingWidget] - _default_sampler_widgets: list[SettingWidget] - _live_sampler_widgets: list[SettingWidget] - def __init__(self, server: Server): super().__init__(_("Style Presets")) self.server = server @@ -644,14 +643,14 @@ def __init__(self, server: Server): frame_layout.addLayout(builtin_layout) frame = QFrame(self) - frame.setFrameStyle(QFrame.StyledPanel) + frame.setFrameStyle(QFrame.Shape.StyledPanel) frame.setLineWidth(1) frame.setLayout(frame_layout) self._layout.addWidget(frame) - self._style_widgets: dict[str, SettingWidget] = {} + self._style_widgets: dict[str, SettingWidgetBase] = {} - def add(name: str, widget: SettingWidget): + def add(name: str, widget: SettingWidgetBase | SettingWidget): self._style_widgets[name] = widget self._layout.addWidget(widget) widget.value_changed.connect(self.write) @@ -682,19 +681,24 @@ def add(name: str, widget: SettingWidget): checkpoint_advanced.toggled.connect(self._toggle_checkpoint_advanced) self._layout.addWidget(checkpoint_advanced) - self._arch_select: ComboBoxSetting = add( + self._arch_select = add( "architecture", ComboBoxSetting(StyleSettings.architecture, parent=self) ) self._vae = add("vae", ComboBoxSetting(StyleSettings.vae, parent=self)) - self._clip_skip = add("clip_skip", SpinBoxSetting(StyleSettings.clip_skip, self, 0, 12)) + self._clip_skip = self._style_widgets["clip_skip"] = SpinBoxSetting( + StyleSettings.clip_skip, self, 0, 12 + ) + self._clip_skip.value_changed.connect(self.write) + self._layout.addWidget(self._clip_skip) self._clip_skip_check = self._clip_skip.add_checkbox(_("Override")) self._clip_skip_check.toggled.connect(self._toggle_clip_skip) - self._resolution_spin = add( - "preferred_resolution", - SpinBoxSetting(StyleSettings.preferred_resolution, self, 0, 2048, step=8), + self._resolution_spin = self._style_widgets["preferred_resolution"] = SpinBoxSetting( + StyleSettings.preferred_resolution, self, 0, 2048, step=8 ) + self._resolution_spin.value_changed.connect(self.write) + self._layout.addWidget(self._resolution_spin) resolution_check = self._resolution_spin.add_checkbox(_("Override")) resolution_check.toggled.connect(self._toggle_preferred_resolution) @@ -719,7 +723,10 @@ def add(name: str, widget: SettingWidget): widget.indent = 1 self._toggle_checkpoint_advanced(False) - add("loras", LoraList(StyleSettings.loras, self)) + self._loras = LoraList(StyleSettings.loras, self) + self._layout.addWidget(self._loras) + self._loras.value_changed.connect(self.write) + add("style_prompt", LineEditSetting(StyleSettings.style_prompt, self)) add("negative_prompt", LineEditSetting(StyleSettings.negative_prompt, self)) @@ -746,8 +753,8 @@ def add(name: str, widget: SettingWidget): _("Open the folder where checkpoints are stored"), self._open_checkpoints_folder, ) - if self._style_widgets["loras"].open_folder_button: - self._style_widgets["loras"].open_folder_button.clicked.connect(self._open_lora_folder) + if self._loras.open_folder_button: + self._loras.open_folder_button.clicked.connect(self._open_lora_folder) self._populate_style_list() Styles.list().changed.connect(self._update_style_list) @@ -915,6 +922,7 @@ def _show_builtin_info(self, style: Style): self._builtin_message.setVisible(is_builtin) self._builtin_copy.setVisible(is_builtin) self._checkpoint_select.setEnabled(not is_builtin) + self._loras.setEnabled(not is_builtin) for widget in self._style_widgets.values(): widget.setEnabled(not is_builtin) for widget in self._checkpoint_advanced_widgets: @@ -947,6 +955,7 @@ def _read_style(self, style: Style): with self._write_guard: for name, widget in self._style_widgets.items(): widget.value = getattr(style, name) + self._loras.value = style.loras # type: ignore self._default_sampler.read(style) self._live_sampler.read(style) self._show_builtin_info(style) @@ -969,6 +978,7 @@ def _write(self): for name, widget in self._style_widgets.items(): if widget.value is not None: setattr(style, name, widget.value) + style.loras = self._loras.value self._write_checkpoint(style) self._default_sampler.write(style) self._live_sampler.write(style) diff --git a/ai_diffusion/ui/switch.py b/ai_diffusion/ui/switch.py index c48fe7be1..3bb61fe20 100644 --- a/ai_diffusion/ui/switch.py +++ b/ai_diffusion/ui/switch.py @@ -2,16 +2,16 @@ from https://stackoverflow.com/a/51825815 """ -from PyQt5.QtCore import QPropertyAnimation, QSize, Qt, pyqtProperty # type: ignore -from PyQt5.QtGui import QPainter -from PyQt5.QtWidgets import QAbstractButton, QSizePolicy +from PyQt6.QtCore import QPropertyAnimation, QSize, Qt, pyqtProperty # type: ignore +from PyQt6.QtGui import QPainter +from PyQt6.QtWidgets import QAbstractButton, QSizePolicy class SwitchWidget(QAbstractButton): def __init__(self, parent=None): super().__init__(parent=parent) self.setCheckable(True) - self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) self._thumb_radius = self.fontMetrics().height() // 2 self._track_radius = self._thumb_radius + 2 @@ -55,7 +55,7 @@ def resizeEvent(self, a0): def paintEvent(self, e): palette = self.palette() p = QPainter(self) - p.setRenderHint(QPainter.Antialiasing, True) + p.setRenderHint(QPainter.RenderHint.Antialiasing, True) p.setPen(Qt.PenStyle.NoPen) track_opacity = 1.0 thumb_opacity = 1.0 diff --git a/ai_diffusion/ui/theme.py b/ai_diffusion/ui/theme.py index 53c13597a..f0e063d9d 100644 --- a/ai_diffusion/ui/theme.py +++ b/ai_diffusion/ui/theme.py @@ -2,9 +2,9 @@ from pathlib import Path -from PyQt5.QtCore import QObject, QSize, Qt -from PyQt5.QtGui import QFontMetrics, QGuiApplication, QIcon, QPalette, QPixmap -from PyQt5.QtWidgets import QLabel, QVBoxLayout, QWidget +from PyQt6.QtCore import QObject, QSize, Qt +from PyQt6.QtGui import QFontMetrics, QGuiApplication, QIcon, QPalette, QPixmap +from PyQt6.QtWidgets import QLabel, QVBoxLayout, QWidget from ..backend.client import Client from ..files import FileFormat @@ -27,7 +27,7 @@ strong_highlight = "#70d0ff" if is_dark else "#2040ff" progress_alt = "#a16207" if is_dark else "#ca8a04" active = _palette.color(QPalette.ColorRole.Highlight).name() -line = _palette.color(QPalette.ColorRole.Background).darker(120).name() +line = _palette.color(QPalette.ColorRole.Window).darker(120).name() line_base = _palette.color(QPalette.ColorRole.Base).darker(120).name() flat_combo_stylesheet = f""" diff --git a/ai_diffusion/ui/upscale.py b/ai_diffusion/ui/upscale.py index a86a9ec6c..92b9e89ec 100644 --- a/ai_diffusion/ui/upscale.py +++ b/ai_diffusion/ui/upscale.py @@ -1,6 +1,6 @@ -from PyQt5.QtCore import QEvent, QMetaObject, Qt, pyqtSignal -from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QEvent, QMetaObject, Qt, pyqtSignal +from PyQt6.QtGui import QCursor, QEnterEvent +from PyQt6.QtWidgets import ( QComboBox, QDoubleSpinBox, QGroupBox, @@ -57,6 +57,8 @@ def __init__(self, parent: QWidget | None): self.input.setSuffix("x") self.input.setDecimals(2) self.input.valueChanged.connect(self.change_factor) + fm = self.input.fontMetrics() + self.input.setMinimumWidth(fm.horizontalAdvance(self.input.prefix() + "4 x") + 10) self.target_label = QLabel(self) self.target_label.setStyleSheet(f"color: {theme.grey};") @@ -99,9 +101,9 @@ def update_target_extent(self): else: self.target_label.setText("") - def enterEvent(self, a0: QEvent | None): + def enterEvent(self, event: QEnterEvent | None): self.update_target_extent() - super().enterEvent(a0) + super().enterEvent(event) def leaveEvent(self, a0: QEvent | None): self.update_target_extent() @@ -137,15 +139,15 @@ def __init__(self): self.refinement_checkbox.setCheckable(True) self.style_select = StyleSelectWidget(self) - self.strength_slider = StrengthWidget(slider_range=(20, 50), prefix=False, parent=self) + self.strength_slider = StrengthWidget(range=(0.2, 0.5), prefix=False) strength_layout = QHBoxLayout() strength_layout.addWidget(QLabel(_("Strength"), self), 1) - strength_layout.addWidget(self.strength_slider, 3) + strength_layout.addWidget(self.strength_slider.widget(), 3) - self.unblur_slider = StrengthWidget(slider_range=(0, 100), prefix=False, parent=self) + self.unblur_slider = StrengthWidget(range=(0.0, 1.0), prefix=False) unblur_layout = QHBoxLayout() unblur_layout.addWidget(QLabel(_("Image guidance"), self), 1) - unblur_layout.addWidget(self.unblur_slider, 3) + unblur_layout.addWidget(self.unblur_slider.widget(), 3) root.connection.models_changed.connect(self._update_style) self.overlap_custom_combo = QComboBox(self) @@ -183,7 +185,6 @@ def __init__(self): group_layout.addLayout(prompt_layout) self.refinement_checkbox.setLayout(group_layout) layout.addWidget(self.refinement_checkbox) - self.factor_widget.input.setMinimumWidth(self.strength_slider._input.width() + 10) self.upscale_button = GenerateButton(JobKind.upscaling, self) self.upscale_button.operation = _("Upscale") diff --git a/ai_diffusion/ui/widget.py b/ai_diffusion/ui/widget.py index 94e600a14..4371a7177 100644 --- a/ai_diffusion/ui/widget.py +++ b/ai_diffusion/ui/widget.py @@ -4,12 +4,14 @@ from itertools import chain from typing import Any, ClassVar, cast -from krita import Krita -from PyQt5.QtCore import QEvent, QMetaObject, QSize, Qt, QUrl, pyqtSignal -from PyQt5.QtGui import ( +from krita import DoubleSliderSpinBox, Krita +from PyQt6.QtCore import QEvent, QMetaObject, QSize, Qt, QUrl, pyqtSignal +from PyQt6.QtGui import ( + QAction, QCloseEvent, QColor, QDesktopServices, + QEnterEvent, QFontMetrics, QGuiApplication, QIcon, @@ -24,8 +26,7 @@ QTextCharFormat, QTextCursor, ) -from PyQt5.QtWidgets import ( - QAction, +from PyQt6.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, @@ -85,12 +86,9 @@ class QueuePopup(QMenu): - _model: DocumentModel - _connections: list[QMetaObject.Connection] - def __init__(self, supports_batch=True, parent: QWidget | None = None): super().__init__(parent) - self._connections = [] + self._connections: list[QMetaObject.Connection | Binding] = [] palette = self.palette() self.setObjectName("QueuePopup") @@ -320,7 +318,7 @@ def _update(self): def sizeHint(self) -> QSize: original = super().sizeHint() - width = original.height() * 0.75 + self.fontMetrics().width(" 99 ") + 20 + width = original.height() * 0.75 + self.fontMetrics().horizontalAdvance(" 99 ") + 20 return QSize(int(width), original.height()) def paintEvent(self, a0): @@ -669,7 +667,7 @@ def move_cursor_to_end(self): def handle_weight_adjustment(self, event: QKeyEvent): """Handles Ctrl + (arrow key up / arrow key down) attention weight adjustment.""" if event.key() in [Qt.Key.Key_Up, Qt.Key.Key_Down] and ( - event.modifiers() & Qt.Modifier.CTRL + event.modifiers() & Qt.KeyboardModifier.ControlModifier ): cursor = self.textCursor() text = self.toPlainText() @@ -688,108 +686,70 @@ def handle_weight_adjustment(self, event: QKeyEvent): start_c16 = str_index_to_char16_index(text, start) cursor = self.textCursor() cursor.setPosition(min(start_c16 + char16_len(text_after_edit), char16_len(text))) - cursor.setPosition(min(start_c16, char16_len(text)), QTextCursor.KeepAnchor) + cursor.setPosition(min(start_c16, char16_len(text)), QTextCursor.MoveMode.KeepAnchor) self.setTextCursor(cursor) -class StrengthSnapping: - model: DocumentModel - - def __init__(self, model: DocumentModel): - self.model = model - - def get_steps(self) -> tuple[int, int]: - is_live = self.model.workspace is Workspace.live - if self.model.workspace is Workspace.animation: - is_live = self.model.animation.sampling_quality is SamplingQuality.fast - return self.model.active_style.get_steps(is_live=is_live) - - def nearest_percent(self, value: int) -> int | None: - _, max_steps = self.get_steps() - steps, start_at_step = self.apply_strength(value) - return snap_to_percent(steps, start_at_step, max_steps=max_steps) - - def apply_strength(self, value: int) -> tuple[int, int]: - min_steps, max_steps = self.get_steps() - strength = value / 100 - return apply_strength(strength, steps=max_steps, min_steps=min_steps) - - -# SpinBox variant that allows manually entering strength values, -# but snaps to model_steps on step actions (scrolling, arrows, arrow keys). -class StrengthSpinBox(QSpinBox): - snapping: StrengthSnapping | None - - def __init__(self, parent=None): - super().__init__(parent) - self.snapping = None - # for manual input - self.setMinimum(1) - self.setMaximum(100) - - def stepBy(self, steps): - value = max(self.minimum(), min(self.maximum(), self.value() + steps)) - if self.snapping is not None: - # keep going until we hit a new snap point - current_point = self.nearest_snap_point(self.value()) - while self.nearest_snap_point(value) == current_point and value > 1: - value += 1 if steps > 0 else -1 - value = self.nearest_snap_point(value) - self.setValue(value) - - def nearest_snap_point(self, value: int) -> int: - assert self.snapping - return self.snapping.nearest_percent(value) or (int(value / 5) * 5) - - -class StrengthWidget(QWidget): - _model: DocumentModel | None = None - _value: int = 100 - +class StrengthWidget(DoubleSliderSpinBox): value_changed = pyqtSignal(float) - def __init__(self, slider_range: tuple[int, int] = (1, 100), prefix=True, parent=None): - super().__init__(parent) - self._layout = QHBoxLayout() - self._layout.setContentsMargins(0, 0, 0, 0) - self.setLayout(self._layout) + def __init__(self, range: tuple[float, float] = (0.01, 1.0), prefix=True): + super().__init__() + self._model: DocumentModel | None = None + self._value = 100 + self._range = (range[0] * 100, range[1] * 100) - self._slider = QSlider(Qt.Orientation.Horizontal, self) - self._slider.setMinimum(slider_range[0]) - self._slider.setMaximum(slider_range[1]) - self._slider.setValue(self._value) - self._slider.setSingleStep(5) - self._slider.valueChanged.connect(self.slider_changed) + self.setSoftMinimum(self._range[0]) + self.setSoftMaximum(self._range[1]) + self.setRange(min(self._range[0], 1), 100, 0) + super().setValue(self._value) - self._input = StrengthSpinBox(self) - self._input.setValue(self._value) + w = self.widget() # the internal QDoubleSpinBox + w.setSingleStep(5) if prefix: - self._input.setPrefix(_("Strength") + ": ") - self._input.setSuffix("%") - self._input.setSpecialValueText(_("Off")) - self._input.valueChanged.connect(self.notify_changed) - - settings.changed.connect(self.update_suffix) - - self._layout.addWidget(self._slider) - self._layout.addWidget(self._input) + w.setPrefix(_("Strength") + ": ") + w.setSuffix("%") + if range[0] == 0: + w.setSpecialValueText(_("Off")) + + w.valueChanged.connect(self.notify_changed) + self.draggingFinished.connect(self._complete_drag) + settings.changed.connect(self._update_suffix) + + def notify_changed(self, value: float): + value = int(value) + if self.isDragging(): + self._change_suffix(value) + return - def slider_changed(self, value: int): - if self._input.snapping is not None: - value = self._input.snapping.nearest_percent(value) or value - self.notify_changed(value) + step = 1 if value - self._value > 0 else -1 + if not self.isDragging() and self._model is not None: + # keep going until we hit a new snap point + current_point = self._nearest_snap_point(self._value) + while self._nearest_snap_point(value) == current_point and value > 1: + value += step + value = self._nearest_snap_point(value) + if self._update_value(value): + self.value_changed.emit(self.value) - def notify_changed(self, value: int): + def _complete_drag(self): + value = int(self.widget().value()) + if settings.show_steps: + value = self._nearest_snap_point(value) if self._update_value(value): self.value_changed.emit(self.value) + def _nearest_snap_point(self, value: int) -> int: + if self._model and (snap := _nearest_percent(self._model, value)): + return snap + return max(round(value / 5) * 5, int(self._range[0])) + def _update_value(self, value: int): - with SignalBlocker(self._slider), SignalBlocker(self._input): - self._slider.setValue(value) - self._input.setValue(value) if value != self._value: self._value = value - self.update_suffix() + with SignalBlocker(self.widget()): + super().setValue(value) + self._update_suffix() return True return False @@ -800,15 +760,14 @@ def model(self): @model.setter def model(self, model: DocumentModel): if self._model: - self._model.style_changed.disconnect(self.update_suffix) - self._model.edit_mode_changed.disconnect(self.update_suffix) - self._model.animation.sampling_quality_changed.disconnect(self.update_suffix) + self._model.style_changed.disconnect(self._update_suffix) + self._model.edit_mode_changed.disconnect(self._update_suffix) + self._model.animation.sampling_quality_changed.disconnect(self._update_suffix) self._model = model - self._model.style_changed.connect(self.update_suffix) - self._model.edit_mode_changed.connect(self.update_suffix) - self._model.animation.sampling_quality_changed.connect(self.update_suffix) - self._input.snapping = StrengthSnapping(self._model) - self.update_suffix() + self._model.style_changed.connect(self._update_suffix) + self._model.edit_mode_changed.connect(self._update_suffix) + self._model.animation.sampling_quality_changed.connect(self._update_suffix) + self._update_suffix() @property def value(self): @@ -816,17 +775,51 @@ def value(self): @value.setter def value(self, value: float): - if value == self.value: + self.setValue(value) + + def _change_suffix(self, value: int): + if not self._model or not settings.show_steps: + self.widget().setSuffix("%") return + + steps, start_at_step = _apply_strength(self._model, value) + self.widget().setSuffix(f"% - {steps - start_at_step}/{steps} steps") + + def _update_suffix(self): + self._change_suffix(self._value) + + def setValue(self, value: float): self._update_value(round(value * 100)) - def update_suffix(self): - if not self._input.snapping or not settings.show_steps: - self._input.setSuffix("%") - return + def setVisible(self, visible: bool): + self.widget().setVisible(visible) + + def setEnabled(self, enabled: bool): + self.widget().setEnabled(enabled) + + def setToolTip(self, tooltip: str): + self.widget().setToolTip(tooltip) - steps, start_at_step = self._input.snapping.apply_strength(self._value) - self._input.setSuffix(f"% ({steps - start_at_step}/{steps})") + +def _get_steps(model: DocumentModel) -> tuple[int, int]: + is_live = model.workspace is Workspace.live + if model.workspace is Workspace.animation: + is_live = model.animation.sampling_quality is SamplingQuality.fast + return model.active_style.get_steps(is_live=is_live) + + +def _apply_strength(model: DocumentModel, strength_percent: int) -> tuple[int, int]: + if strength_percent <= 0: + return 0, 0 + min_steps, max_steps = _get_steps(model) + strength = strength_percent / 100 + return apply_strength(strength, steps=max_steps, min_steps=min_steps) + + +def _nearest_percent(model: DocumentModel, strength_percent: int) -> int | None: + _, max_steps = _get_steps(model) + steps, start_at_step = _apply_strength(model, strength_percent) + return snap_to_percent(steps, start_at_step, max_steps=max_steps) class LayerCountWidget(QWidget): @@ -903,7 +896,7 @@ def __init__(self, parent): self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.setMenu(menu) - self.setPopupMode(QToolButton.InstantPopup) + self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self.setToolTip( _("Switch between workspaces: image generation, upscaling, live preview and animation.") ) @@ -962,9 +955,9 @@ def operation(self, value: str): def minimumSizeHint(self): fm = self.fontMetrics() - return QSize(fm.width(self._operation) + 40, 12 + int(1.3 * fm.height())) + return QSize(fm.horizontalAdvance(self._operation) + 40, 12 + int(1.3 * fm.height())) - def enterEvent(self, a0: QEvent | None): + def enterEvent(self, event: QEnterEvent | None): if (client := root.connection.client_if_connected) and client.user: self._cost = self.model.estimate_cost(self._kind) @@ -986,7 +979,7 @@ def mouseReleaseEvent(self, e: QMouseEvent | None): def paintEvent(self, a0: QPaintEvent | None) -> None: opt = QStyleOption() opt.initFrom(self) - opt.state |= QStyle.StateFlag.State_Sunken if self.isDown() else 0 + opt.state |= QStyle.StateFlag.State_Sunken if self.isDown() else QStyle.StateFlag(0) painter = QPainter(self) fm = self.fontMetrics() style = ensure(self.style()) @@ -998,9 +991,9 @@ def paintEvent(self, a0: QPaintEvent | None) -> None: rect = self.rect() pixmap = self.icon().pixmap(int(fm.height() * 1.3)) pixmap_width = _get_width_dip(pixmap) - is_hover = int(opt.state) & QStyle.StateFlag.State_MouseOver + is_hover = opt.state & QStyle.StateFlag.State_MouseOver element = QStyle.PrimitiveElement.PE_PanelButtonCommand - content_width = fm.width(self._operation) + 5 + pixmap_width + content_width = fm.horizontalAdvance(self._operation) + 5 + pixmap_width content_rect = rect.adjusted(int(0.5 * (rect.width() - content_width)), 0, 0, 0) style.drawPrimitive(element, opt, painter, self) style.drawItemPixmap(painter, content_rect, align, pixmap) @@ -1010,7 +1003,7 @@ def paintEvent(self, a0: QPaintEvent | None) -> None: cost_width = 0 if is_hover and self._cost > 0: pixmap = self._cost_icon.pixmap(fm.height()) - text_width = fm.width(str(self._cost)) + text_width = fm.horizontalAdvance(str(self._cost)) cost_width = text_width + 16 + pixmap_width cost_rect = rect.adjusted(rect.width() - cost_width, 0, 0, 0) painter.setOpacity(0.3) @@ -1187,7 +1180,7 @@ def _paint_tool_drop_down(widget: QToolButton, text: str | None = None): rect = widget.rect() pixmap = widget.icon().pixmap(int(rect.height() * 0.75)) element = QStyle.PrimitiveElement.PE_Widget - if int(opt.state) & QStyle.StateFlag.State_MouseOver: + if opt.state & QStyle.StateFlag.State_MouseOver: element = QStyle.PrimitiveElement.PE_PanelButtonCommand style.drawPrimitive(element, opt, painter, widget) style.drawItemPixmap(painter, rect.adjusted(4, 0, 0, 0), align, pixmap) diff --git a/ai_diffusion/util.py b/ai_diffusion/util.py index e4df64abd..8032d10ef 100644 --- a/ai_diffusion/util.py +++ b/ai_diffusion/util.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import Any, TypeVar -from PyQt5 import sip -from PyQt5.QtCore import QObject, QStandardPaths +from PyQt6 import sip +from PyQt6.QtCore import QObject, QStandardPaths T = TypeVar("T") R = TypeVar("R") @@ -35,11 +35,13 @@ def _get_user_data_dir(): return dir try: - dir = Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) + dir = Path(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)) if dir.exists() and "krita" in dir.name.lower(): dir = dir / "ai_diffusion" else: - dir = Path(QStandardPaths.writableLocation(QStandardPaths.GenericDataLocation)) + dir = Path( + QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericDataLocation) + ) dir = dir / "krita-ai-diffusion" dir.mkdir(exist_ok=True) except Exception: diff --git a/requirements.txt b/requirements.txt index 0a149366e..506077f88 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ pytest pytest-asyncio psutil tqdm -PyQt5 +PyQt6 Pillow # Service diff --git a/scripts/design.py b/scripts/design.py index b0f573c5e..c7c4efb4c 100644 --- a/scripts/design.py +++ b/scripts/design.py @@ -10,9 +10,9 @@ from collections.abc import Generator from pathlib import Path -from PyQt5.QtCore import QByteArray, Qt, QTimer -from PyQt5.QtGui import QImage, QPixmap -from PyQt5.QtWidgets import ( +from PyQt6.QtCore import QByteArray, Qt, QTimer +from PyQt6.QtGui import QImage, QPixmap +from PyQt6.QtWidgets import ( QApplication, QCheckBox, QGroupBox, @@ -276,10 +276,10 @@ def _update_image(self) -> None: return w, h = self._doc.width(), self._doc.height() if self._active_node is not None: - raw = bytes(self._active_node.pixelData(0, 0, w, h)) + raw = self._active_node.pixelData(0, 0, w, h).data() self.setTitle(f"Layer: {self._active_node.name()}") else: - raw = bytes(self._doc.pixelData(0, 0, w, h)) + raw = self._doc.pixelData(0, 0, w, h).data() self.setTitle("Document") # Krita returns BGRA bytes; Format_ARGB32 uses the same memory layout on # little-endian systems (x86), so no channel swapping is needed. @@ -335,7 +335,7 @@ def _on_toggled(self, state: int) -> None: class ControlPane(QWidget): """Side panel that lets you manipulate fake Krita state while inspecting the UI.""" - def __init__(self, dock: QWidget, parent=None): + def __init__(self, dock: ImageDiffusionWidget, parent=None): super().__init__(parent) self._dock = dock self._doc_counter = 0 @@ -481,12 +481,12 @@ def main(): layout = QHBoxLayout(container) layout.addWidget(controls) layout.addWidget(dock, stretch=1) - container.resize(1600, 1400) + container.resize(1200, 1400) container.show() if args.exit: QTimer.singleShot(0, app.quit) - sys.exit(app.exec_()) + sys.exit(app.exec()) if __name__ == "__main__": diff --git a/scripts/typeshed/krita.pyi b/scripts/typeshed/krita.pyi index 4205616b2..1cb6539c4 100644 --- a/scripts/typeshed/krita.pyi +++ b/scripts/typeshed/krita.pyi @@ -6,9 +6,9 @@ IMPORTANT: The interfaces here are intended to match Krita's actual API. DO NOT modify unless it is to fix bugs or add missing parts. """ -from PyQt5.QtCore import * -from PyQt5.QtGui import * -from PyQt5.QtWidgets import * +from PyQt6.QtCore import * +from PyQt6.QtGui import * +from PyQt6.QtWidgets import * from typing import List, Dict, Optional, Union KisPresetChooser = QObject @@ -1407,8 +1407,8 @@ class Extension(QObject): """* An Extension is the base for classes that extend Krita. An Extension is loaded on startup, when the setup() method will be executed. The extension instance should be added to the Krita Application object using Krita.instance().addViewExtension or Application.addViewExtension or Scripter.addViewExtension. Example: @code import sys - from PyQt5.QtGui import * - from PyQt5.QtWidgets import * + from PyQt6.QtGui import * + from PyQt6.QtWidgets import * from krita import * class HelloExtension(Extension): def __init__(self, parent): @@ -2679,3 +2679,17 @@ class CloneLayer(Node): """@access public Q_SLOTS @brief setSourceNode @param node the node to use as the source of the clone layer.""" + +class DoubleParseSpinBox(QObject): + def __init__(self) -> None: ... + def widget(self) -> QDoubleSpinBox: ... + def stepBy(self, steps: int) -> None: ... + +class DoubleSliderSpinBox(DoubleParseSpinBox): + draggingFinished = pyqtSignal() + def __init__(self) -> None: ... + def setRange(self, min: float, max: float, decimals: int=0, compute_fast_step: bool=True) -> None: ... + def setValue(self, value: float) -> None:... + def setSoftMinimum(self, value: float) -> None: ... + def setSoftMaximum(self, value: float) -> None: ... + def isDragging(self) -> bool: ... diff --git a/tests/conftest.py b/tests/conftest.py index da559d70a..e921602ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,7 @@ import aiohttp import psutil import pytest -from PyQt5.QtCore import QCoreApplication +from PyQt6.QtCore import QCoreApplication sys.path.append(str(Path(__file__).parent.parent)) from ai_diffusion import eventloop, util diff --git a/tests/mock/krita.py b/tests/mock/krita.py index 996494c3c..00c3560da 100644 --- a/tests/mock/krita.py +++ b/tests/mock/krita.py @@ -6,9 +6,9 @@ from __future__ import annotations -from PyQt5.QtCore import QByteArray, QObject, QRect, QUuid, pyqtSignal -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QDockWidget +from PyQt6.QtCore import QByteArray, QObject, QRect, Qt, QUuid, pyqtSignal +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QDockWidget, QDoubleSpinBox, QHBoxLayout, QSlider, QWidget IS_MOCK = True @@ -324,7 +324,7 @@ def setPixelData(self, value: QByteArray, x: int, y: int, w: int, h: int) -> boo b = self._bounds if b[2] == 0 or b[3] == 0: self._bounds = (x, y, w, h) - self._pixel_data = bytearray(value) + self._pixel_data = bytearray(value.data()) self.pixelDataChanged.emit() return True @@ -338,12 +338,12 @@ def setPixelData(self, value: QByteArray, x: int, y: int, w: int, h: int) -> boo # expand pixel data to match the new bounds img = self._to_image() img = Image.crop(img, Bounds(*self._bounds)) - self._pixel_data = bytearray(img.to_packed_bytes()) + self._pixel_data = bytearray(img.to_packed_bytes().data()) src = Image.from_packed_bytes(value, Extent(w, h)) dst = self._to_image() dst.draw_image(src, (x, y), blend=BlendMode.replace) - self._pixel_data = dst.to_packed_bytes() + self._pixel_data = bytearray(dst.to_packed_bytes().data()) self.pixelDataChanged.emit() return True @@ -351,7 +351,7 @@ def pixelDataAtTime(self, x: int, y: int, w: int, h: int, time: int) -> QByteArr return QByteArray(bytes(w * h * 4)) def thumbnail(self, w: int, h: int): - from PyQt5.QtGui import QImage + from PyQt6.QtGui import QImage return QImage(w, h, QImage.Format.Format_ARGB32) @@ -441,7 +441,7 @@ def pixelData(self, x: int, y: int, w: int, h: int) -> QByteArray: y1 = min(y + h, self._y + self._height) if x0 >= x1 or y0 >= y1: return QByteArray(bytes(result)) - src = bytes(self._data) + src = self._data.data() n = x1 - x0 for ry in range(y0, y1): src_off = (ry - self._y) * self._width + (x0 - self._x) @@ -698,3 +698,63 @@ def openDocument(self, filename: str) -> Document: self._documents.append(doc) self._active_document = doc return doc + + +class DoubleParseSpinBox(QWidget): + def __init__(self): + super().__init__() + + self._layout = QHBoxLayout(self) + self.setLayout(self._layout) + + self._widget = QDoubleSpinBox() + self._layout.addWidget(self._widget) + + def widget(self): + return self._widget + + def stepBy(self, steps: int): + self._widget.stepBy(steps) + + +class DoubleSliderSpinBox(DoubleParseSpinBox): + draggingFinished = pyqtSignal() + + def __init__(self): + super().__init__() + self._slider = QSlider(Qt.Orientation.Horizontal, self) + self._layout.insertWidget(0, self._slider) + + self._slider.valueChanged.connect(self._update_spinbox_from_slider) + self.widget().valueChanged.connect(self._update_slider_from_spinbox) + + def setRange(self, min: float, max: float, decimals: int = 0, compute_fast_step: bool = True): + self.widget().setRange(min, max) + self.widget().setDecimals(decimals) + self._slider.setRange(int(min), int(max)) + + def setSoftMinimum(self, min: float): + pass + + def setSoftMaximum(self, max: float): + pass + + def _update_slider_from_spinbox(self): + value = self.widget().value() + if abs(self._slider.value() / 100.0 - value) > 0.01: + self._slider.setValue(int(value * 100)) + + def _update_spinbox_from_slider(self): + value = self._slider.value() / 100.0 + if abs(self.widget().value() - value) > 0.01: + self.widget().setValue(value) + + def setValue(self, value: float): + self.widget().setValue(value) + self._slider.setValue(int(value * 100)) + + def value(self): + return self.widget().value() + + def isDragging(self) -> bool: + return self._slider.isSliderDown() diff --git a/tests/test_api.py b/tests/test_api.py index cef814c96..b8997ea7b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,4 +1,4 @@ -from PyQt5.QtCore import Qt +from PyQt6.QtCore import Qt from ai_diffusion.backend.api import ( ConditioningInput, diff --git a/tests/test_custom_workflow.py b/tests/test_custom_workflow.py index 526a2a631..45f151e1a 100644 --- a/tests/test_custom_workflow.py +++ b/tests/test_custom_workflow.py @@ -5,7 +5,7 @@ from pathlib import Path import pytest -from PyQt5.QtCore import Qt +from PyQt6.QtCore import Qt from ai_diffusion.backend import workflow from ai_diffusion.backend.api import ( @@ -541,7 +541,7 @@ def test_job_info_output(): def img_id(image: Image): data = image.to_bytes() - hash = zlib.crc32(data) + hash = zlib.crc32(data.data()) return f"{hash:08x}" diff --git a/tests/test_document.py b/tests/test_document.py index a185d7e2a..18badea45 100644 --- a/tests/test_document.py +++ b/tests/test_document.py @@ -2,7 +2,7 @@ from krita import Document as MockDocument from krita import Krita, Selection -from PyQt5.QtCore import QByteArray +from PyQt6.QtCore import QByteArray from ai_diffusion.document import KritaDocument from ai_diffusion.image import Bounds diff --git a/tests/test_files.py b/tests/test_files.py index a2b1323c6..f7f67b76d 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -1,7 +1,7 @@ from pathlib import Path -from PyQt5.QtCore import QModelIndex, Qt -from PyQt5.QtGui import QIcon +from PyQt6.QtCore import QModelIndex, Qt +from PyQt6.QtGui import QIcon from ai_diffusion.files import File, FileCollection, FileFilter, FileSource diff --git a/tests/test_image.py b/tests/test_image.py index 38ef98b5f..e72d01d75 100644 --- a/tests/test_image.py +++ b/tests/test_image.py @@ -4,8 +4,8 @@ import numpy as np import pytest from PIL import Image as PILImage -from PyQt5.QtCore import QByteArray, Qt -from PyQt5.QtGui import QImage, qRgba +from PyQt6.QtCore import QByteArray, Qt +from PyQt6.QtGui import QImage, qRgba from ai_diffusion.image import Bounds, Extent, Image, ImageCollection, Mask @@ -27,7 +27,7 @@ def test_extent_scale_pixel_count(): def create_test_image(w, h): - img = QImage(w, h, QImage.Format_ARGB32) + img = QImage(w, h, QImage.Format.Format_ARGB32) for y in range(h): for x in range(w): img.setPixel(x, y, qRgba(x, y, 0, 255)) @@ -41,7 +41,7 @@ def test_image_rgba(): def test_image_mask(): - qimg = QImage(2, 5, QImage.Format_Grayscale8) + qimg = QImage(2, 5, QImage.Format.Format_Grayscale8) qimg.fill(123) img = Image(qimg) assert img.extent == Extent(2, 5) @@ -57,7 +57,7 @@ def test_base64(): def test_image_make_opaque(): - img = Image(QImage(2, 2, QImage.Format_ARGB32)) + img = Image(QImage(2, 2, QImage.Format.Format_ARGB32)) img.set_pixel(0, 0, (0, 0, 0, 0)) img.set_pixel(1, 0, (0, 0, 0, 155)) img.set_pixel(0, 1, (42, 42, 42, 255)) @@ -116,8 +116,8 @@ def test_image_from_packed_bytes(): def test_image_compress_speed(): from timeit import default_timer - from PyQt5.QtCore import QBuffer, QByteArray, QFile, QIODevice - from PyQt5.QtGui import QImageWriter + from PyQt6.QtCore import QBuffer, QByteArray, QFile, QIODevice + from PyQt6.QtGui import QImageWriter img = Image.load("tests/images/beach_1536x1024.webp") @@ -139,7 +139,7 @@ def test_image_compress_speed(): file = QFile(f"beach_1536x1024_q{q}.webp") file.open(QIODevice.OpenModeFlag.WriteOnly) - file.write(byte_array) + file.write(byte_array.data()) file.close() from io import BytesIO diff --git a/tests/test_image_transfer.py b/tests/test_image_transfer.py index f61314719..094eb05a7 100644 --- a/tests/test_image_transfer.py +++ b/tests/test_image_transfer.py @@ -52,7 +52,7 @@ async def test_send(format: str, mode: str): results = ImageCollection.from_bytes(result_bytes, transfer["offsets"]) for result, expected in zip(results, images): - assert result.to_numpy_format() == ImageWrapper.from_pil(expected).to_numpy_format() + assert ImageWrapper.compare(result, ImageWrapper.from_pil(expected)) < 0.01 @pytest.mark.parametrize("mode", ["b64", "transfer"]) @qtapp @@ -67,7 +67,8 @@ async def test_receive(cloud_service: CloudService, mode: str): input = {"image_data": {"bytes": bytes, "offsets": offsets}} user = await cloud_service.create_user("image-transfer-test") - client = await CloudClient.connect(cloud_service.url, user["token"]) + client = CloudClient(cloud_service.url, user["token"]) + await client.connect() await client.send_images(input, max_inline_size=max_b64_size) if mode == "transfer": diff --git a/tests/test_layer.py b/tests/test_layer.py index e00871435..903d650fd 100644 --- a/tests/test_layer.py +++ b/tests/test_layer.py @@ -3,7 +3,7 @@ from __future__ import annotations from krita import Document -from PyQt5.QtCore import Qt +from PyQt6.QtCore import Qt from ai_diffusion.eventloop import process_python_events from ai_diffusion.image import BlendMode, Bounds, Extent, Image diff --git a/tests/test_model.py b/tests/test_model.py index e0cc8702b..d0c13771e 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -11,7 +11,7 @@ import pytest from krita import Document as MockKritaDocument from krita import Krita, Selection -from PyQt5.QtCore import QByteArray, Qt +from PyQt6.QtCore import QByteArray, Qt from ai_diffusion.backend.api import WorkflowInput, WorkflowKind from ai_diffusion.backend.client import CheckpointInfo, ClientEvent, ClientMessage diff --git a/tests/test_properties.py b/tests/test_properties.py index 28ad51c5f..2d73ca167 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -1,7 +1,7 @@ from enum import Enum import pytest -from PyQt5.QtCore import QObject, pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal from ai_diffusion.model.properties import ( ObservableProperties, diff --git a/tests/test_server.py b/tests/test_server.py index 8f833ab79..b730d879c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,7 +5,7 @@ from tempfile import TemporaryDirectory import pytest -from PyQt5.QtNetwork import QNetworkAccessManager +from PyQt6.QtNetwork import QNetworkAccessManager from ai_diffusion.backend import network, resources, server from ai_diffusion.backend.resources import VerificationState diff --git a/tests/test_updates.py b/tests/test_updates.py index 427eb4cf6..786946dc9 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -3,7 +3,7 @@ import pytest from aiohttp import ClientSession -from PyQt5.QtCore import pyqtBoundSignal +from PyQt6.QtCore import pyqtBoundSignal from ai_diffusion.model.updates import AutoUpdate, UpdateState from ai_diffusion.platform_tools import ZipFile