feat(ui): expanded thumbnail and preview features (#390)
* Fix text and RAW image handling - Fix RAW images not being loaded correctly in the preview panel - Fix trying to read size data from null images - Refactor `os.stat` to `<Path object>.stat()` - Remove unnecessary upper/lower conversions - Improve encoding compatibility beyond UTF-8 when reading text files - Code cleanup * Use chardet for character encoding detection * Add support for waveform + album cover thumbnails * Rename "cover" variables for MyPy * Rename "audio_tags" variables for MyPy + typing * Add # type: ignore to fromstring method * Add GIF preview support * Add rough check for invalid video codecs * Add ".plist" to PLAINTEXT_TYPES * Add readable video tester * Add ".psd" to IMAGE_TYPES; Handle ID3NoHeaderError * Improve and style waveform previews * Add final return statement to _album_artwork() * Add final return statement to _audio_waveform() * Tweak waveform color and size * Fix ItemThumb label text color in light mode * Fix most theme UI legibility issues * Match additional UI to color scheme * ruff format * feat(ui): add UI color palette dict * feat(ui) center and color small font previews * fix(ui): large font previews follow app theme * fix(ui): blender previews follow app theme * feat(ui): add resizable thumbnail options * fix: mkv files with "[0][0][0][0]" codec load properly * fix: missing audio files properly handled * feat(ui): use system accent color for thumb selections * fix(ui): hide gif preview in multi-selections * feat(ui): add dynamic file thumb icons * fix(ui): hide previous thumbnail before resizing * (fix): catch ffmpeg errors in file tester * Squashed commit of the following: commit9a3c19d398Author: Travis Abendshien <lvnvtravis@gmail.com> Date: Wed Jul 24 22:57:32 2024 -0700 fix: add missing comma + sort extensions commit53b2db9b5fAuthor: Travis Abendshien <lvnvtravis@gmail.com> Date: Wed Jul 24 14:46:16 2024 -0700 refactor: move type constants to new media classes * feat(ui): add media types and icon resources * feat(ui): add more default media types and icons Add additional default icons for: - Blender - Presentation - Program - Spreadsheet Add/expand additional media types: - PDF - Packages * fix: remove leading dot in preview panel ext * refactor: remove edge from `four_corner_gradient()` * fix: handle missing files in `resource_manager` * fix(ui): thumb edges fading on refresh * feat(ui): add default icons for audio+vector thumbs * feat(ui): apply edge to default icon thumbs * chore: remove unused code * refactor(ui): move loading icon to `ResourceManager` * fix(ui) color for default icons follow theme * fix: remove `theme_color` redef * refactor: make some consts and args clearer * refactor: organize arguments, update docstrings The ability to pass a border radius scaling argument is also included. * chore: format docstrings with ruff * refactor: replace magic numbers with named values * refactor: remove unused code, comments, & imports * refactor: rename args to not shadow builtins * refactor: remove unused vars from `thumb_renderer` * fix: handle ValueError in `render()` Handle ValueErrors in `render()`. This case was encountered when attempting to render an `XPM` file during testing. * docs: add FFmpeg requirement to README
@@ -63,6 +63,8 @@ If you're interested in contributing to TagStudio, please take a look at the [co
|
||||
|
||||
To download TagStudio, visit the [Releases](https://github.com/TagStudioDev/TagStudio/releases) section of the GitHub repository and download the latest release for your system under the "Assets" section. TagStudio is available for **Windows**, **macOS** _(Apple Silicon & Intel)_, and **Linux**. Windows and Linux builds are also available in portable versions if you want a more self-contained executable to move around.
|
||||
|
||||
For video thumbnails and playback, you'll also need [FFmpeg](https://ffmpeg.org/download.html) installed on your system.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> On macOS, you may be met with a message saying _""TagStudio" can't be opened because Apple cannot check it for malicious software."_ If you encounter this, then you'll need to go to the "Settings" app, navigate to "Privacy & Security", and scroll down to a section that says _""TagStudio" was blocked from use because it is not from an identified developer."_ Click the "Open Anyway" button to allow TagStudio to run. You should only have to do this once after downloading the application.
|
||||
|
||||
|
||||
@@ -10,3 +10,7 @@ numpy==1.26.4
|
||||
rawpy==0.21.0
|
||||
pillow-heif==0.16.0
|
||||
chardet==5.2.0
|
||||
pydub==0.25.1
|
||||
mutagen==1.47.0
|
||||
numpy==1.26.4
|
||||
ffmpeg-python==0.2.0
|
||||
|
||||
BIN
tagstudio/resources/qt/images/broken_link_icon.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_illustrator.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/adobe_photoshop.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/file_icons/affinity_photo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/audio.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
tagstudio/resources/qt/images/file_icons/document.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/file_generic.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/font.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
tagstudio/resources/qt/images/file_icons/image_vector.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
tagstudio/resources/qt/images/file_icons/material.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
tagstudio/resources/qt/images/file_icons/model.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
tagstudio/resources/qt/images/file_icons/presentation.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
tagstudio/resources/qt/images/file_icons/program.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/spreadsheet.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
tagstudio/resources/qt/images/file_icons/text.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
tagstudio/resources/qt/images/file_icons/video.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 12 KiB |
BIN
tagstudio/resources/qt/images/thumb_loading.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -55,6 +55,5 @@ TAG_COLORS = [
|
||||
"cool gray",
|
||||
"olive",
|
||||
]
|
||||
|
||||
TAG_FAVORITE = 1
|
||||
TAG_ARCHIVED = 0
|
||||
|
||||
@@ -12,7 +12,9 @@ class SettingItems(str, enum.Enum):
|
||||
|
||||
|
||||
class Theme(str, enum.Enum):
|
||||
COLOR_BG = "#65000000"
|
||||
COLOR_BG_DARK = "#65000000"
|
||||
COLOR_BG_LIGHT = "#22000000"
|
||||
COLOR_DARK_LABEL = "#DD000000"
|
||||
COLOR_HOVER = "#65AAAAAA"
|
||||
COLOR_PRESSED = "#65EEEEEE"
|
||||
COLOR_DISABLED = "#65F39CAA"
|
||||
|
||||
@@ -13,7 +13,10 @@ logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
class MediaType(str, Enum):
|
||||
"""Names of media types."""
|
||||
|
||||
ADOBE_PHOTOSHOP: str = "adobe_photoshop"
|
||||
AFFINITY_PHOTO: str = "affinity_photo"
|
||||
ARCHIVE: str = "archive"
|
||||
AUDIO_MIDI: str = "audio_midi"
|
||||
AUDIO: str = "audio"
|
||||
BLENDER: str = "blender"
|
||||
DATABASE: str = "database"
|
||||
@@ -27,7 +30,7 @@ class MediaType(str, Enum):
|
||||
MATERIAL: str = "material"
|
||||
MODEL: str = "model"
|
||||
PACKAGE: str = "package"
|
||||
PHOTOSHOP: str = "photoshop"
|
||||
PDF: str = "pdf"
|
||||
PLAINTEXT: str = "plaintext"
|
||||
PRESENTATION: str = "presentation"
|
||||
PROGRAM: str = "program"
|
||||
@@ -67,6 +70,12 @@ class MediaCategories:
|
||||
# These sets are used either individually or together to form the final sets
|
||||
# for the MediaCategory(s).
|
||||
# These sets may be combined and are NOT 1:1 with the final categories.
|
||||
_ADOBE_PHOTOSHOP_SET: set[str] = {
|
||||
".pdd",
|
||||
".psb",
|
||||
".psd",
|
||||
}
|
||||
_AFFINITY_PHOTO_SET: set[str] = {".afphoto"}
|
||||
_ARCHIVE_SET: set[str] = {
|
||||
".7z",
|
||||
".gz",
|
||||
@@ -76,6 +85,10 @@ class MediaCategories:
|
||||
".tgz",
|
||||
".zip",
|
||||
}
|
||||
_AUDIO_MIDI_SET: set[str] = {
|
||||
".mid",
|
||||
".midi",
|
||||
}
|
||||
_AUDIO_SET: set[str] = {
|
||||
".aac",
|
||||
".aif",
|
||||
@@ -182,6 +195,7 @@ class MediaCategories:
|
||||
".jpg_large",
|
||||
".jpg",
|
||||
".jpg2",
|
||||
".jxl",
|
||||
".png",
|
||||
".psb",
|
||||
".psd",
|
||||
@@ -192,11 +206,17 @@ class MediaCategories:
|
||||
_INSTALLER_SET: set[str] = {".appx", ".msi", ".msix"}
|
||||
_MATERIAL_SET: set[str] = {".mtl"}
|
||||
_MODEL_SET: set[str] = {".3ds", ".fbx", ".obj", ".stl"}
|
||||
_PACKAGE_SET: set[str] = {".pkg"}
|
||||
_PHOTOSHOP_SET: set[str] = {
|
||||
".pdd",
|
||||
".psb",
|
||||
".psd",
|
||||
_PACKAGE_SET: set[str] = {
|
||||
".aab",
|
||||
".akp",
|
||||
".apk",
|
||||
".apkm",
|
||||
".apks",
|
||||
".pkg",
|
||||
".xapk",
|
||||
}
|
||||
_PDF_SET: set[str] = {
|
||||
".pdf",
|
||||
}
|
||||
_PLAINTEXT_SET: set[str] = {
|
||||
".bat",
|
||||
@@ -247,14 +267,29 @@ class MediaCategories:
|
||||
".wmv",
|
||||
}
|
||||
|
||||
ADOBE_PHOTOSHOP_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.ADOBE_PHOTOSHOP,
|
||||
extensions=_ADOBE_PHOTOSHOP_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AFFINITY_PHOTO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AFFINITY_PHOTO,
|
||||
extensions=_AFFINITY_PHOTO_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
ARCHIVE_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.ARCHIVE,
|
||||
extensions=_ARCHIVE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AUDIO_MIDI_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AUDIO_MIDI,
|
||||
extensions=_AUDIO_MIDI_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
AUDIO_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.AUDIO,
|
||||
extensions=_AUDIO_SET,
|
||||
extensions=_AUDIO_SET | _AUDIO_MIDI_SET,
|
||||
is_iana=True,
|
||||
)
|
||||
BLENDER_TYPES: MediaCategory = MediaCategory(
|
||||
@@ -317,9 +352,9 @@ class MediaCategories:
|
||||
extensions=_PACKAGE_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PHOTOSHOP_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PHOTOSHOP,
|
||||
extensions=_PHOTOSHOP_SET,
|
||||
PDF_TYPES: MediaCategory = MediaCategory(
|
||||
media_type=MediaType.PDF,
|
||||
extensions=_PDF_SET,
|
||||
is_iana=False,
|
||||
)
|
||||
PLAINTEXT_TYPES: MediaCategory = MediaCategory(
|
||||
@@ -359,7 +394,10 @@ class MediaCategories:
|
||||
)
|
||||
|
||||
ALL_CATEGORIES: list[MediaCategory] = [
|
||||
ADOBE_PHOTOSHOP_TYPES,
|
||||
AFFINITY_PHOTO_TYPES,
|
||||
ARCHIVE_TYPES,
|
||||
AUDIO_MIDI_TYPES,
|
||||
AUDIO_TYPES,
|
||||
BLENDER_TYPES,
|
||||
DATABASE_TYPES,
|
||||
@@ -373,7 +411,7 @@ class MediaCategories:
|
||||
MATERIAL_TYPES,
|
||||
MODEL_TYPES,
|
||||
PACKAGE_TYPES,
|
||||
PHOTOSHOP_TYPES,
|
||||
PDF_TYPES,
|
||||
PLAINTEXT_TYPES,
|
||||
PRESENTATION_TYPES,
|
||||
PROGRAM_TYPES,
|
||||
|
||||
@@ -13,7 +13,7 @@ class ColorType(int, Enum):
|
||||
DARK_ACCENT = 4
|
||||
|
||||
|
||||
_TAG_COLORS = {
|
||||
_TAG_COLORS: dict = {
|
||||
"": {
|
||||
ColorType.PRIMARY: "#1e1e1e",
|
||||
ColorType.TEXT: ColorType.LIGHT_ACCENT,
|
||||
@@ -277,13 +277,58 @@ _TAG_COLORS = {
|
||||
},
|
||||
}
|
||||
|
||||
_UI_COLORS: dict = {
|
||||
"": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"green": {
|
||||
ColorType.PRIMARY: "#28bb48",
|
||||
ColorType.BORDER: "#43c568",
|
||||
ColorType.LIGHT_ACCENT: "#DDFFCC",
|
||||
ColorType.DARK_ACCENT: "#0d3828",
|
||||
},
|
||||
"purple": {
|
||||
ColorType.PRIMARY: "#C76FF3",
|
||||
ColorType.BORDER: "#c364f2",
|
||||
ColorType.LIGHT_ACCENT: "#EFD4FB",
|
||||
ColorType.DARK_ACCENT: "#3E1555",
|
||||
},
|
||||
"red": {
|
||||
ColorType.PRIMARY: "#e22c3c",
|
||||
ColorType.BORDER: "#e54252",
|
||||
ColorType.LIGHT_ACCENT: "#f39caa",
|
||||
ColorType.DARK_ACCENT: "#440d12",
|
||||
},
|
||||
"theme_dark": {
|
||||
ColorType.PRIMARY: "#333333",
|
||||
ColorType.BORDER: "#555555",
|
||||
ColorType.LIGHT_ACCENT: "#FFFFFF",
|
||||
ColorType.DARK_ACCENT: "#1e1e1e",
|
||||
},
|
||||
"theme_light": {
|
||||
ColorType.PRIMARY: "#FFFFFF",
|
||||
ColorType.BORDER: "#333333",
|
||||
ColorType.LIGHT_ACCENT: "#999999",
|
||||
ColorType.DARK_ACCENT: "#888888",
|
||||
},
|
||||
}
|
||||
|
||||
def get_tag_color(type, color):
|
||||
|
||||
def get_tag_color(color_type, color):
|
||||
color = color.lower()
|
||||
try:
|
||||
if type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][type], color)
|
||||
if color_type == ColorType.TEXT:
|
||||
return get_tag_color(_TAG_COLORS[color][color_type], color)
|
||||
else:
|
||||
return _TAG_COLORS[color][type]
|
||||
return _TAG_COLORS[color][color_type]
|
||||
except KeyError:
|
||||
return "#FF00FF"
|
||||
|
||||
|
||||
def get_ui_color(color_type: ColorType, color: str):
|
||||
"""Returns a hex value given a color name and ColorType."""
|
||||
color = color.lower()
|
||||
return _UI_COLORS.get(color).get(color_type)
|
||||
|
||||
@@ -10,23 +10,28 @@ from src.qt.helpers.gradient import linear_gradient
|
||||
|
||||
# TODO: Consolidate the built-in QT theme values with the values
|
||||
# here, in enums.py, and in palette.py.
|
||||
_THEME_DARK_FG: str = "#FFFFFF55"
|
||||
_THEME_DARK_FG: str = "#FFFFFF77"
|
||||
_THEME_LIGHT_FG: str = "#000000DD"
|
||||
_THEME_DARK_BG: str = "#000000DD"
|
||||
_THEME_LIGHT_BG: str = "#FFFFFF55"
|
||||
|
||||
|
||||
def theme_fg_overlay(image: Image.Image) -> Image.Image:
|
||||
def theme_fg_overlay(image: Image.Image, use_alpha: bool = True) -> Image.Image:
|
||||
"""
|
||||
Overlay the foreground theme color onto an image.
|
||||
|
||||
Args:
|
||||
image (Image): The PIL Image object to apply an overlay to.
|
||||
"""
|
||||
dark_fg: str = _THEME_DARK_FG[:-2] if not use_alpha else _THEME_DARK_FG
|
||||
light_fg: str = _THEME_LIGHT_FG[:-2] if not use_alpha else _THEME_LIGHT_FG
|
||||
|
||||
overlay_color = (
|
||||
_THEME_DARK_FG
|
||||
dark_fg
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else _THEME_LIGHT_FG
|
||||
else light_fg
|
||||
)
|
||||
|
||||
im = Image.new(mode="RGBA", size=image.size, color=overlay_color)
|
||||
return _apply_overlay(image, im)
|
||||
|
||||
|
||||
29
tagstudio/src/qt/helpers/file_tester.py
Normal file
@@ -0,0 +1,29 @@
|
||||
# Copyright (C) 2024 Travis Abendshien (CyanVoxel).
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
|
||||
import ffmpeg
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def is_readable_video(filepath: Path | str):
|
||||
"""Test if a video is in a readable format. Examples of unreadable videos
|
||||
include files with undetermined codecs and DRM-protected content.
|
||||
|
||||
Args:
|
||||
filepath (Path | str):
|
||||
"""
|
||||
try:
|
||||
probe = ffmpeg.probe(Path(filepath))
|
||||
for stream in probe["streams"]:
|
||||
# DRM check
|
||||
if stream.get("codec_tag_string") in [
|
||||
"drma",
|
||||
"drms",
|
||||
"drmi",
|
||||
]:
|
||||
return False
|
||||
except ffmpeg.Error:
|
||||
return False
|
||||
return True
|
||||
@@ -2,24 +2,14 @@
|
||||
# Licensed under the GPL-3.0 License.
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PIL import Image, ImageEnhance, ImageChops
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def four_corner_gradient_background(
|
||||
image: Image.Image, adj_size, mask, hl
|
||||
def four_corner_gradient(
|
||||
image: Image.Image, size: tuple[int, int], mask: Image.Image
|
||||
) -> Image.Image:
|
||||
if image.size != (adj_size, adj_size):
|
||||
# Old 1 color method.
|
||||
# bg_col = image.copy().resize((1, 1)).getpixel((0,0))
|
||||
# bg = Image.new(mode='RGB',size=(adj_size,adj_size),color=bg_col)
|
||||
# bg.thumbnail((1, 1))
|
||||
# bg = bg.resize((adj_size,adj_size), resample=Image.Resampling.NEAREST)
|
||||
|
||||
# Small gradient background. Looks decent, and is only a one-liner.
|
||||
# bg = image.copy().resize((2, 2), resample=Image.Resampling.BILINEAR).resize((adj_size,adj_size),resample=Image.Resampling.BILINEAR)
|
||||
|
||||
if image.size != size:
|
||||
# Four-Corner Gradient Background.
|
||||
# Not exactly a one-liner, but it's (subjectively) really cool.
|
||||
tl = image.getpixel((0, 0))
|
||||
tr = image.getpixel(((image.size[0] - 1), 0))
|
||||
bl = image.getpixel((0, (image.size[1] - 1)))
|
||||
@@ -29,26 +19,25 @@ def four_corner_gradient_background(
|
||||
bg.paste(tr, (1, 0, 2, 2))
|
||||
bg.paste(bl, (0, 1, 2, 2))
|
||||
bg.paste(br, (1, 1, 2, 2))
|
||||
bg = bg.resize((adj_size, adj_size), resample=Image.Resampling.BICUBIC)
|
||||
|
||||
bg = bg.resize(size, resample=Image.Resampling.BICUBIC)
|
||||
bg.paste(
|
||||
image,
|
||||
box=(
|
||||
(adj_size - image.size[0]) // 2,
|
||||
(adj_size - image.size[1]) // 2,
|
||||
(size[0] - image.size[0]) // 2,
|
||||
(size[1] - image.size[1]) // 2,
|
||||
),
|
||||
)
|
||||
|
||||
bg.putalpha(mask)
|
||||
final = bg
|
||||
final = Image.new("RGBA", bg.size, (0, 0, 0, 0))
|
||||
final.paste(bg, mask=mask.getchannel(0))
|
||||
|
||||
else:
|
||||
image.putalpha(mask)
|
||||
final = image
|
||||
final = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
final.paste(image, mask=mask.getchannel(0))
|
||||
|
||||
if final.mode != "RGBA":
|
||||
final = final.convert("RGBA")
|
||||
|
||||
hl_soft = hl.copy()
|
||||
hl_soft.putalpha(ImageEnhance.Brightness(hl.getchannel(3)).enhance(0.5))
|
||||
final.paste(ImageChops.soft_light(final, hl_soft), mask=hl_soft.getchannel(3))
|
||||
return final
|
||||
|
||||
|
||||
|
||||
31
tagstudio/src/qt/helpers/rounded_pixmap_style.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Based on the implementation by eyllanesc:
|
||||
# https://stackoverflow.com/questions/54230005/qmovie-with-border-radius
|
||||
# Licensed under the Creative Commons CC BY-SA 4.0 License:
|
||||
# https://creativecommons.org/licenses/by-sa/4.0/
|
||||
# Modified for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
from PySide6.QtGui import QBrush, QColor, QPainter, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QProxyStyle,
|
||||
)
|
||||
|
||||
|
||||
class RoundedPixmapStyle(QProxyStyle):
|
||||
def __init__(self, radius=8):
|
||||
super().__init__()
|
||||
self._radius = radius
|
||||
|
||||
def drawItemPixmap(self, painter, rectangle, alignment, pixmap):
|
||||
painter.save()
|
||||
pix = QPixmap(pixmap.size())
|
||||
pix.fill(QColor("transparent"))
|
||||
p = QPainter(pix)
|
||||
p.setBrush(QBrush(pixmap))
|
||||
p.setPen(QColor("transparent"))
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
p.drawRoundedRect(pixmap.rect(), self._radius, self._radius)
|
||||
p.end()
|
||||
super(RoundedPixmapStyle, self).drawItemPixmap(
|
||||
painter, rectangle, alignment, pix
|
||||
)
|
||||
painter.restore()
|
||||
@@ -66,7 +66,7 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.horizontalLayout = QHBoxLayout()
|
||||
self.horizontalLayout.setObjectName(u"horizontalLayout")
|
||||
|
||||
# ComboBox goup for search type and thumbnail size
|
||||
# ComboBox group for search type and thumbnail size
|
||||
self.horizontalLayout_3 = QHBoxLayout()
|
||||
self.horizontalLayout_3.setObjectName("horizontalLayout_3")
|
||||
|
||||
@@ -83,17 +83,17 @@ class Ui_MainWindow(QMainWindow):
|
||||
self.horizontalLayout_3.addWidget(self.comboBox_2)
|
||||
|
||||
# Thumbnail Size placeholder
|
||||
self.comboBox = QComboBox(self.centralwidget)
|
||||
self.comboBox.setObjectName(u"comboBox")
|
||||
self.thumb_size_combobox = QComboBox(self.centralwidget)
|
||||
self.thumb_size_combobox.setObjectName(u"thumbSizeComboBox")
|
||||
sizePolicy = QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
|
||||
sizePolicy.setHorizontalStretch(0)
|
||||
sizePolicy.setVerticalStretch(0)
|
||||
sizePolicy.setHeightForWidth(
|
||||
self.comboBox.sizePolicy().hasHeightForWidth())
|
||||
self.comboBox.setSizePolicy(sizePolicy)
|
||||
self.comboBox.setMinimumWidth(128)
|
||||
self.comboBox.setMaximumWidth(128)
|
||||
self.horizontalLayout_3.addWidget(self.comboBox)
|
||||
self.thumb_size_combobox.sizePolicy().hasHeightForWidth())
|
||||
self.thumb_size_combobox.setSizePolicy(sizePolicy)
|
||||
self.thumb_size_combobox.setMinimumWidth(128)
|
||||
self.thumb_size_combobox.setMaximumWidth(352)
|
||||
self.horizontalLayout_3.addWidget(self.thumb_size_combobox)
|
||||
self.gridLayout.addLayout(self.horizontalLayout_3, 5, 0, 1, 1)
|
||||
|
||||
self.splitter = QSplitter()
|
||||
@@ -212,10 +212,10 @@ class Ui_MainWindow(QMainWindow):
|
||||
# Search type selector
|
||||
self.comboBox_2.setItemText(0, QCoreApplication.translate("MainWindow", "And (Includes All Tags)"))
|
||||
self.comboBox_2.setItemText(1, QCoreApplication.translate("MainWindow", "Or (Includes Any Tag)"))
|
||||
self.comboBox.setCurrentText("")
|
||||
self.thumb_size_combobox.setCurrentText("")
|
||||
|
||||
# Thumbnail size selector
|
||||
self.comboBox.setPlaceholderText(
|
||||
self.thumb_size_combobox.setPlaceholderText(
|
||||
QCoreApplication.translate("MainWindow", u"Thumbnail Size", None))
|
||||
# retranslateUi
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from PIL import Image
|
||||
|
||||
import ujson
|
||||
|
||||
@@ -46,19 +47,30 @@ class ResourceManager:
|
||||
return cached_res
|
||||
else:
|
||||
res: dict = ResourceManager._map.get(id)
|
||||
if res.get("mode") in ["r", "rb"]:
|
||||
with open(
|
||||
(Path(__file__).parents[2] / "resources" / res.get("path")),
|
||||
res.get("mode"),
|
||||
) as f:
|
||||
data = f.read()
|
||||
if res.get("mode") == "rb":
|
||||
data = bytes(data)
|
||||
ResourceManager._cache[id] = data
|
||||
try:
|
||||
if res and res.get("mode") in ["r", "rb"]:
|
||||
with open(
|
||||
(Path(__file__).parents[2] / "resources" / res.get("path")),
|
||||
res.get("mode"),
|
||||
) as f:
|
||||
data = f.read()
|
||||
if res.get("mode") == "rb":
|
||||
data = bytes(data)
|
||||
ResourceManager._cache[id] = data
|
||||
return data
|
||||
elif res and res.get("mode") == "pil":
|
||||
data = Image.open(
|
||||
Path(__file__).parents[2] / "resources" / res.get("path")
|
||||
)
|
||||
return data
|
||||
elif res.get("mode") in ["qt"]:
|
||||
# TODO: Qt resource loading logic
|
||||
pass
|
||||
elif res and res.get("mode") in ["qt"]:
|
||||
# TODO: Qt resource loading logic
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
logging.error(
|
||||
f"[ResourceManager][ERROR]: Could not find resource: {Path(__file__).parents[2] / "resources" / res.get("path")}"
|
||||
)
|
||||
return None
|
||||
|
||||
def __getattr__(self, __name: str) -> Any:
|
||||
attr = self.get(__name)
|
||||
|
||||
@@ -14,5 +14,81 @@
|
||||
"volume_mute_icon": {
|
||||
"path": "qt/images/volume_mute.svg",
|
||||
"mode": "rb"
|
||||
},
|
||||
"broken_link_icon": {
|
||||
"path": "qt/images/broken_link_icon.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"adobe_illustrator": {
|
||||
"path": "qt/images/file_icons/adobe_illustrator.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"adobe_photoshop": {
|
||||
"path": "qt/images/file_icons/adobe_photoshop.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"affinity_photo": {
|
||||
"path": "qt/images/file_icons/affinity_photo.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"audio": {
|
||||
"path": "qt/images/file_icons/audio.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"blender": {
|
||||
"path": "qt/images/file_icons/blender.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"document": {
|
||||
"path": "qt/images/file_icons/document.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"file_generic": {
|
||||
"path": "qt/images/file_icons/file_generic.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"font": {
|
||||
"path": "qt/images/file_icons/font.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"image": {
|
||||
"path": "qt/images/file_icons/image.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"image_vector": {
|
||||
"path": "qt/images/file_icons/image_vector.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"material": {
|
||||
"path": "qt/images/file_icons/material.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"model": {
|
||||
"path": "qt/images/file_icons/model.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"presentation": {
|
||||
"path": "qt/images/file_icons/presentation.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"program": {
|
||||
"path": "qt/images/file_icons/program.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"spreadsheet": {
|
||||
"path": "qt/images/file_icons/spreadsheet.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"text": {
|
||||
"path": "qt/images/file_icons/text.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"video": {
|
||||
"path": "qt/images/file_icons/video.png",
|
||||
"mode": "pil"
|
||||
},
|
||||
"thumb_loading": {
|
||||
"path": "qt/images/thumb_loading.png",
|
||||
"mode": "pil"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,11 +557,17 @@ class QtDriver(QObject):
|
||||
str(Path(__file__).parents[2] / "resources/qt/fonts/Oxanium-Bold.ttf")
|
||||
)
|
||||
|
||||
self.thumb_sizes: list[tuple[str, int]] = [
|
||||
("Extra Large Thumbnails", 256),
|
||||
("Large Thumbnails", 192),
|
||||
("Medium Thumbnails", 128),
|
||||
("Small Thumbnails", 96),
|
||||
("Mini Thumbnails", 76),
|
||||
]
|
||||
self.thumb_size = 128
|
||||
self.max_results = 500
|
||||
self.item_thumbs: list[ItemThumb] = []
|
||||
self.thumb_renderers: list[ThumbRenderer] = []
|
||||
self.collation_thumb_size = math.ceil(self.thumb_size * 2)
|
||||
|
||||
self.init_library_window()
|
||||
|
||||
@@ -596,23 +602,35 @@ class QtDriver(QObject):
|
||||
self.shutdown()
|
||||
|
||||
def init_library_window(self):
|
||||
# self._init_landing_page() # Taken care of inside the widget now
|
||||
self._init_thumb_grid()
|
||||
|
||||
# TODO: Put this into its own method that copies the font file(s) into memory
|
||||
# so the resource isn't being used, then store the specific size variations
|
||||
# in a global dict for methods to access for different DPIs.
|
||||
# adj_font_size = math.floor(12 * self.main_window.devicePixelRatio())
|
||||
# self.ext_font = ImageFont.truetype(os.path.normpath(f'{Path(__file__).parents[2]}/resources/qt/fonts/Oxanium-Bold.ttf'), adj_font_size)
|
||||
|
||||
# Search Button
|
||||
search_button: QPushButton = self.main_window.searchButton
|
||||
search_button.clicked.connect(
|
||||
lambda: self.filter_items(self.main_window.searchField.text())
|
||||
)
|
||||
|
||||
# Search Field
|
||||
search_field: QLineEdit = self.main_window.searchField
|
||||
search_field.returnPressed.connect(
|
||||
lambda: self.filter_items(self.main_window.searchField.text())
|
||||
)
|
||||
|
||||
# Thumbnail Size ComboBox
|
||||
thumb_size_combobox: QComboBox = self.main_window.thumb_size_combobox
|
||||
for size in self.thumb_sizes:
|
||||
thumb_size_combobox.addItem(size[0])
|
||||
thumb_size_combobox.setCurrentIndex(2) # Default: Medium
|
||||
thumb_size_combobox.currentIndexChanged.connect(
|
||||
lambda: self.thumb_size_callback(thumb_size_combobox.currentIndex())
|
||||
)
|
||||
self._init_thumb_grid()
|
||||
|
||||
# Search Type ComboBox
|
||||
search_type_selector: QComboBox = self.main_window.comboBox_2
|
||||
search_type_selector.currentIndexChanged.connect(
|
||||
lambda: self.set_search_type(
|
||||
@@ -1099,6 +1117,37 @@ class QtDriver(QObject):
|
||||
else:
|
||||
self.paste_entry_fields_action.setText("&Paste Fields")
|
||||
|
||||
def thumb_size_callback(self, index: int):
|
||||
"""
|
||||
Performs actions needed when the thumbnail size selection is changed.
|
||||
|
||||
Args:
|
||||
index (int): The index of the item_thumbs/ComboBox list to use.
|
||||
"""
|
||||
SPACING_DIVISOR: int = 10
|
||||
MIN_SPACING: int = 12
|
||||
# Index 2 is the default (Medium)
|
||||
if index < len(self.thumb_sizes) and index >= 0:
|
||||
self.thumb_size = self.thumb_sizes[index][1]
|
||||
else:
|
||||
logging.error(
|
||||
f"ERROR: Invalid thumbnail size index ({index}). Defaulting to 128px."
|
||||
)
|
||||
self.thumb_size = 128
|
||||
|
||||
self.update_thumbs()
|
||||
blank_icon: QIcon = QIcon()
|
||||
for it in self.item_thumbs:
|
||||
it.thumb_button.setIcon(blank_icon)
|
||||
it.resize(self.thumb_size, self.thumb_size)
|
||||
it.thumb_size = (self.thumb_size, self.thumb_size)
|
||||
it.setMinimumSize(self.thumb_size, self.thumb_size)
|
||||
it.setMaximumSize(self.thumb_size, self.thumb_size)
|
||||
it.thumb_button.thumb_size = (self.thumb_size, self.thumb_size)
|
||||
self.flow_container.layout().setSpacing(
|
||||
min(self.thumb_size // SPACING_DIVISOR, MIN_SPACING)
|
||||
)
|
||||
|
||||
def mouse_navigation(self, event: QMouseEvent):
|
||||
# print(event.button())
|
||||
if event.button() == Qt.MouseButton.ForwardButton:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
# Created for TagStudio: https://github.com/CyanVoxel/TagStudio
|
||||
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
@@ -12,24 +11,15 @@ from PIL import Image, ImageChops, UnidentifiedImageError
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import (
|
||||
QObject,
|
||||
QThread,
|
||||
Signal,
|
||||
QRunnable,
|
||||
Qt,
|
||||
QThreadPool,
|
||||
QSize,
|
||||
QEvent,
|
||||
QTimer,
|
||||
QSettings,
|
||||
)
|
||||
|
||||
from src.core.library import Library
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
|
||||
ERROR = f"[ERROR]"
|
||||
WARNING = f"[WARNING]"
|
||||
INFO = f"[INFO]"
|
||||
ERROR = "[ERROR]"
|
||||
WARNING = "[WARNING]"
|
||||
INFO = "[INFO]"
|
||||
|
||||
|
||||
logging.basicConfig(format="%(message)s", level=logging.INFO)
|
||||
@@ -53,7 +43,6 @@ class CollageIconRenderer(QObject):
|
||||
):
|
||||
entry = self.lib.get_entry(entry_id)
|
||||
filepath = self.lib.library_dir / entry.path / entry.filename
|
||||
file_type = os.path.splitext(filepath)[1].lower()[1:]
|
||||
color: str = ""
|
||||
|
||||
try:
|
||||
@@ -85,14 +74,11 @@ class CollageIconRenderer(QObject):
|
||||
|
||||
if data_only_mode:
|
||||
pic = Image.new("RGB", size, color)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not data_only_mode:
|
||||
logging.info(
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}{os.sep}{entry.filename}\033[0m"
|
||||
f"\r{INFO} Combining [ID:{entry_id}/{len(self.lib.entries)}]: {self.get_file_color(filepath.suffix.lower())}{entry.path}/{entry.filename}\033[0m"
|
||||
)
|
||||
# sys.stdout.write(f'\r{INFO} Combining [{i+1}/{len(self.lib.entries)}]: {self.get_file_color(file_type)}{entry.path}{os.sep}{entry.filename}{RESET}')
|
||||
# sys.stdout.flush()
|
||||
ext: str = filepath.suffix.lower()
|
||||
if MediaType.IMAGE in MediaCategories.get_types(ext):
|
||||
try:
|
||||
@@ -108,39 +94,36 @@ class CollageIconRenderer(QObject):
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except DecompressionBombError as e:
|
||||
logging.info(f"[ERROR] One of the images was too big ({e})")
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
if not success:
|
||||
# Depending on the video format, compression, and frame
|
||||
# count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
if is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
if not success:
|
||||
# Depending on the video format, compression, and frame
|
||||
# count, seeking halfway does not work and the thumb
|
||||
# must be pulled from the earliest available frame.
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
with Image.fromarray(frame, mode="RGB") as pic:
|
||||
if keep_aspect:
|
||||
pic.thumbnail(size)
|
||||
else:
|
||||
pic = pic.resize(size)
|
||||
if data_tint_mode and color:
|
||||
pic = ImageChops.hard_light(
|
||||
pic, Image.new("RGB", size, color)
|
||||
)
|
||||
self.rendered.emit(pic)
|
||||
except (UnidentifiedImageError, FileNotFoundError):
|
||||
logging.info(
|
||||
f"\n{ERROR} Couldn't read {entry.path}{os.sep}{entry.filename}"
|
||||
)
|
||||
logging.info(f"\n{ERROR} Couldn't read {entry.path}/{entry.filename}")
|
||||
with Image.open(
|
||||
str(
|
||||
Path(__file__).parents[2]
|
||||
@@ -151,22 +134,16 @@ class CollageIconRenderer(QObject):
|
||||
if data_tint_mode and color:
|
||||
pic = pic.convert(mode="RGB")
|
||||
pic = ImageChops.hard_light(pic, Image.new("RGB", size, color))
|
||||
# collage.paste(pic, (y*thumb_size, x*thumb_size))
|
||||
self.rendered.emit(pic)
|
||||
except KeyboardInterrupt:
|
||||
# self.quit(save=False, backup=True)
|
||||
run = False
|
||||
# clear()
|
||||
logging.info("\n")
|
||||
logging.info(f"{INFO} Collage operation cancelled.")
|
||||
clear_scr = False
|
||||
except:
|
||||
logging.info(f"{ERROR} {entry.path}{os.sep}{entry.filename}")
|
||||
except Exception:
|
||||
logging.info(f"{ERROR} {entry.path}/{entry.filename}")
|
||||
traceback.print_exc()
|
||||
logging.info("Continuing...")
|
||||
|
||||
self.done.emit()
|
||||
# logging.info('Done!')
|
||||
|
||||
# NOTE: Depreciated
|
||||
def get_file_color(self, ext: str):
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
|
||||
|
||||
import math
|
||||
import os
|
||||
from types import FunctionType, MethodType
|
||||
from pathlib import Path
|
||||
from typing import Optional, cast, Callable, Any
|
||||
from typing import Optional, cast, Callable
|
||||
|
||||
from PIL import Image, ImageQt
|
||||
from PySide6.QtCore import Qt, QEvent
|
||||
from PySide6.QtGui import QPixmap, QEnterEvent
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.helpers.color_overlay import theme_fg_overlay
|
||||
|
||||
|
||||
class FieldContainer(QWidget):
|
||||
@@ -35,22 +35,21 @@ class FieldContainer(QWidget):
|
||||
|
||||
def __init__(self, title: str = "Field", inline: bool = True) -> None:
|
||||
super().__init__()
|
||||
# self.mode:str = mode
|
||||
self.setObjectName("fieldContainer")
|
||||
# self.item = item
|
||||
self.title: str = title
|
||||
self.inline: bool = inline
|
||||
# self.editable:bool = editable
|
||||
self.copy_callback: FunctionType = None
|
||||
self.edit_callback: FunctionType = None
|
||||
self.remove_callback: Callable = None
|
||||
button_size = 24
|
||||
# self.setStyleSheet('border-style:solid;border-color:#1e1a33;border-radius:8px;border-width:2px;')
|
||||
|
||||
self.clipboard_icon_128 = theme_fg_overlay(FieldContainer.clipboard_icon_128)
|
||||
self.edit_icon_128 = theme_fg_overlay(FieldContainer.edit_icon_128)
|
||||
self.trash_icon_128 = theme_fg_overlay(FieldContainer.trash_icon_128)
|
||||
|
||||
self.root_layout = QVBoxLayout(self)
|
||||
self.root_layout.setObjectName("baseLayout")
|
||||
self.root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
# self.setStyleSheet('background-color:red;')
|
||||
|
||||
self.inner_layout = QVBoxLayout()
|
||||
self.inner_layout.setObjectName("innerLayout")
|
||||
@@ -62,7 +61,6 @@ class FieldContainer(QWidget):
|
||||
self.root_layout.addWidget(self.inner_container)
|
||||
|
||||
self.title_container = QWidget()
|
||||
# self.title_container.setStyleSheet('background:black;')
|
||||
self.title_layout = QHBoxLayout(self.title_container)
|
||||
self.title_layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_layout.setObjectName("fieldLayout")
|
||||
@@ -75,9 +73,7 @@ class FieldContainer(QWidget):
|
||||
self.title_widget.setObjectName("fieldTitle")
|
||||
self.title_widget.setWordWrap(True)
|
||||
self.title_widget.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
# self.title_widget.setStyleSheet('background-color:orange;')
|
||||
self.title_widget.setText(title)
|
||||
# self.inner_layout.addWidget(self.title_widget)
|
||||
self.title_layout.addWidget(self.title_widget)
|
||||
|
||||
self.title_layout.addStretch(2)
|
||||
@@ -119,11 +115,8 @@ class FieldContainer(QWidget):
|
||||
self.field_layout.setObjectName("fieldLayout")
|
||||
self.field_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.field_container.setLayout(self.field_layout)
|
||||
# self.field_container.setStyleSheet('background-color:#666600;')
|
||||
self.inner_layout.addWidget(self.field_container)
|
||||
|
||||
# self.set_inner_widget(mode)
|
||||
|
||||
def set_copy_callback(self, callback: Optional[MethodType]):
|
||||
if self.copy_button.is_connected:
|
||||
self.copy_button.clicked.disconnect()
|
||||
@@ -151,12 +144,7 @@ class FieldContainer(QWidget):
|
||||
self.remove_button.is_connected = True
|
||||
|
||||
def set_inner_widget(self, widget: "FieldWidget"):
|
||||
# widget.setStyleSheet('background-color:green;')
|
||||
# self.inner_container.dumpObjectTree()
|
||||
# logging.info('')
|
||||
if self.field_layout.itemAt(0):
|
||||
# logging.info(f'Removing {self.field_layout.itemAt(0)}')
|
||||
# self.field_layout.removeItem(self.field_layout.itemAt(0))
|
||||
self.field_layout.itemAt(0).widget().deleteLater()
|
||||
self.field_layout.addWidget(widget)
|
||||
|
||||
@@ -172,12 +160,7 @@ class FieldContainer(QWidget):
|
||||
def set_inline(self, inline: bool):
|
||||
self.inline = inline
|
||||
|
||||
# def set_editable(self, editable:bool):
|
||||
# self.editable = editable
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
# if self.field_layout.itemAt(1):
|
||||
# self.field_layout.itemAt(1).
|
||||
# NOTE: You could pass the hover event to the FieldWidget if needed.
|
||||
if self.copy_callback:
|
||||
self.copy_button.setHidden(False)
|
||||
@@ -202,5 +185,4 @@ class FieldWidget(QWidget):
|
||||
|
||||
def __init__(self, title) -> None:
|
||||
super().__init__()
|
||||
# self.item = item
|
||||
self.title = title
|
||||
|
||||
@@ -62,27 +62,29 @@ class ItemThumb(FlowWidget):
|
||||
tag_group_icon_128.load()
|
||||
|
||||
small_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:12px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
med_text_style = (
|
||||
f"background-color:rgba(0, 0, 0, 192);"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:18px;"
|
||||
f"border-radius:3px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
"background-color:rgba(0, 0, 0, 192);"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:18px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -361,12 +363,15 @@ class ItemThumb(FlowWidget):
|
||||
and (MediaType.IMAGE not in MediaCategories.get_types(ext))
|
||||
or (MediaType.IMAGE_RAW in MediaCategories.get_types(ext))
|
||||
or (MediaType.IMAGE_VECTOR in MediaCategories.get_types(ext))
|
||||
or (MediaType.PHOTOSHOP in MediaCategories.get_types(ext))
|
||||
or (MediaType.ADOBE_PHOTOSHOP in MediaCategories.get_types(ext))
|
||||
or ext
|
||||
in [
|
||||
".apng",
|
||||
".avif",
|
||||
".exr",
|
||||
".gif",
|
||||
".jxl",
|
||||
".webp",
|
||||
]
|
||||
):
|
||||
self.ext_badge.setHidden(False)
|
||||
|
||||
@@ -7,13 +7,12 @@ from pathlib import Path
|
||||
import time
|
||||
import typing
|
||||
from datetime import datetime as dt
|
||||
|
||||
import cv2
|
||||
import rawpy
|
||||
from PIL import Image, UnidentifiedImageError, ImageFont
|
||||
from PIL.Image import DecompressionBombError
|
||||
from PySide6.QtCore import QModelIndex, Signal, Qt, QSize
|
||||
from PySide6.QtGui import QResizeEvent, QAction
|
||||
from PySide6.QtGui import QGuiApplication, QResizeEvent, QAction, QMovie
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QVBoxLayout,
|
||||
@@ -27,13 +26,13 @@ from PySide6.QtWidgets import (
|
||||
QMessageBox,
|
||||
)
|
||||
from humanfriendly import format_size
|
||||
|
||||
from src.core.enums import SettingItems, Theme
|
||||
from src.core.library import Entry, ItemType, Library
|
||||
from src.core.constants import (
|
||||
TS_FOLDER_NAME,
|
||||
)
|
||||
from src.core.media_types import MediaCategories, MediaType
|
||||
from src.qt.helpers.rounded_pixmap_style import RoundedPixmapStyle
|
||||
from src.qt.helpers.file_opener import FileOpenerLabel, FileOpenerHelper, open_file
|
||||
from src.qt.modals.add_field import AddFieldModal
|
||||
from src.qt.widgets.thumb_renderer import ThumbRenderer
|
||||
@@ -45,6 +44,7 @@ from src.qt.widgets.text_box_edit import EditTextBox
|
||||
from src.qt.widgets.text_line_edit import EditTextLine
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
from src.qt.widgets.video_player import VideoPlayer
|
||||
from src.qt.helpers.file_tester import is_readable_video
|
||||
|
||||
|
||||
# Only import for type checking/autocompletion, will not be imported at runtime.
|
||||
@@ -81,6 +81,17 @@ class PreviewPanel(QWidget):
|
||||
self.img_button_size: tuple[int, int] = (266, 266)
|
||||
self.image_ratio: float = 1.0
|
||||
|
||||
self.label_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_DARK_LABEL.value
|
||||
)
|
||||
self.panel_bg_color = (
|
||||
Theme.COLOR_BG_DARK.value
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else Theme.COLOR_BG_LIGHT.value
|
||||
)
|
||||
|
||||
self.image_container = QWidget()
|
||||
image_layout = QHBoxLayout(self.image_container)
|
||||
image_layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -92,9 +103,17 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.setMinimumSize(*self.img_button_size)
|
||||
self.preview_img.setFlat(True)
|
||||
self.preview_img.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
|
||||
self.preview_img.addAction(self.open_file_action)
|
||||
self.preview_img.addAction(self.open_explorer_action)
|
||||
|
||||
self.preview_gif = QLabel()
|
||||
self.preview_gif.setMinimumSize(*self.img_button_size)
|
||||
self.preview_gif.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
|
||||
self.preview_gif.setCursor(Qt.CursorShape.ArrowCursor)
|
||||
self.preview_gif.addAction(self.open_file_action)
|
||||
self.preview_gif.addAction(self.open_explorer_action)
|
||||
self.preview_gif.hide()
|
||||
|
||||
self.preview_vid = VideoPlayer(driver)
|
||||
self.preview_vid.hide()
|
||||
self.thumb_renderer = ThumbRenderer()
|
||||
@@ -116,6 +135,8 @@ class PreviewPanel(QWidget):
|
||||
|
||||
image_layout.addWidget(self.preview_img)
|
||||
image_layout.setAlignment(self.preview_img, Qt.AlignmentFlag.AlignCenter)
|
||||
image_layout.addWidget(self.preview_gif)
|
||||
image_layout.setAlignment(self.preview_gif, Qt.AlignmentFlag.AlignCenter)
|
||||
image_layout.addWidget(self.preview_vid)
|
||||
image_layout.setAlignment(self.preview_vid, Qt.AlignmentFlag.AlignCenter)
|
||||
self.image_container.setMinimumSize(*self.img_button_size)
|
||||
@@ -132,15 +153,16 @@ class PreviewPanel(QWidget):
|
||||
# Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
|
||||
properties_style = (
|
||||
f"background-color:{Theme.COLOR_BG.value};"
|
||||
f"font-family:Oxanium;"
|
||||
f"font-weight:bold;"
|
||||
f"font-size:12px;"
|
||||
f"border-radius:6px;"
|
||||
f"padding-top: 4px;"
|
||||
f"padding-right: 1px;"
|
||||
f"padding-bottom: 1px;"
|
||||
f"padding-left: 1px;"
|
||||
f"background-color:{self.label_bg_color};"
|
||||
"color:#FFFFFF;"
|
||||
"font-family:Oxanium;"
|
||||
"font-weight:bold;"
|
||||
"font-size:12px;"
|
||||
"border-radius:3px;"
|
||||
"padding-top: 4px;"
|
||||
"padding-right: 1px;"
|
||||
"padding-bottom: 1px;"
|
||||
"padding-left: 1px;"
|
||||
)
|
||||
|
||||
self.dimensions_label.setStyleSheet(properties_style)
|
||||
@@ -171,9 +193,10 @@ class PreviewPanel(QWidget):
|
||||
# background and NOT the scroll container background, so that the
|
||||
# rounded corners are maintained when scrolling. I was unable to
|
||||
# find the right trick to only select that particular element.
|
||||
|
||||
scroll_area.setStyleSheet(
|
||||
"QWidget#entryScrollContainer{"
|
||||
f"background: {Theme.COLOR_BG.value};"
|
||||
f"background:{self.panel_bg_color};"
|
||||
"border-radius:6px;"
|
||||
"}"
|
||||
)
|
||||
@@ -278,6 +301,7 @@ class PreviewPanel(QWidget):
|
||||
clear_layout(layout)
|
||||
|
||||
label = QLabel("Recent Libraries")
|
||||
label.setStyleSheet("font-weight:bold;")
|
||||
label.setAlignment(Qt.AlignCenter) # type: ignore
|
||||
|
||||
row_layout = QHBoxLayout()
|
||||
@@ -288,11 +312,9 @@ class PreviewPanel(QWidget):
|
||||
btn: QPushButtonWrapper | QPushButton, extras: list[str] | None = None
|
||||
):
|
||||
base_style = [
|
||||
f"background-color:{Theme.COLOR_BG.value};",
|
||||
f"background-color:{self.panel_bg_color};",
|
||||
"border-radius:6px;",
|
||||
"text-align: left;",
|
||||
"padding-top: 3px;",
|
||||
"padding-left: 6px;",
|
||||
"padding-bottom: 4px;",
|
||||
]
|
||||
|
||||
@@ -323,11 +345,11 @@ class PreviewPanel(QWidget):
|
||||
return lambda: self.driver.open_library(Path(path))
|
||||
|
||||
button.clicked.connect(open_library_button_clicked(full_val))
|
||||
set_button_style(button)
|
||||
button_remove = QPushButton("➖")
|
||||
set_button_style(button, ["padding-left: 6px;", "text-align: left;"])
|
||||
button_remove = QPushButton("—")
|
||||
button_remove.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button_remove.setFixedWidth(30)
|
||||
set_button_style(button_remove)
|
||||
button_remove.setFixedWidth(24)
|
||||
set_button_style(button_remove, ["font-weight:bold;", "text-align:center;"])
|
||||
|
||||
def remove_recent_library_clicked(key: str):
|
||||
return lambda: (
|
||||
@@ -396,20 +418,14 @@ class PreviewPanel(QWidget):
|
||||
self.preview_vid.resizeVideo(adj_size)
|
||||
self.preview_vid.setMaximumSize(adj_size)
|
||||
self.preview_vid.setMinimumSize(adj_size)
|
||||
# self.preview_img.setMinimumSize(adj_size)
|
||||
|
||||
# if self.preview_img.iconSize().toTuple()[0] < self.preview_img.size().toTuple()[0] + 10:
|
||||
# if type(self.item) == Entry:
|
||||
# filepath = os.path.normpath(f'{self.lib.library_dir}/{self.item.path}/{self.item.filename}')
|
||||
# self.thumb_renderer.render(time.time(), filepath, self.preview_img.size().toTuple(), self.devicePixelRatio(),update_on_ratio_change=True)
|
||||
|
||||
# logging.info(f' Img Aspect Ratio: {self.image_ratio}')
|
||||
# logging.info(f' Max Button Size: {size}')
|
||||
# logging.info(f'Container Size: {(self.image_container.size().width(), self.image_container.size().height())}')
|
||||
# logging.info(f'Final Button Size: {(adj_width, adj_height)}')
|
||||
# logging.info(f'')
|
||||
# logging.info(f' Icon Size: {self.preview_img.icon().actualSize().toTuple()}')
|
||||
# logging.info(f'Button Size: {self.preview_img.size().toTuple()}')
|
||||
self.preview_gif.setMaximumSize(adj_size)
|
||||
self.preview_gif.setMinimumSize(adj_size)
|
||||
proxy_style = RoundedPixmapStyle(radius=8)
|
||||
self.preview_gif.setStyle(proxy_style)
|
||||
self.preview_vid.setStyle(proxy_style)
|
||||
m = self.preview_gif.movie()
|
||||
if m:
|
||||
m.setScaledSize(adj_size)
|
||||
|
||||
def place_add_field_button(self):
|
||||
self.scroll_layout.addWidget(self.afb_container)
|
||||
@@ -479,6 +495,7 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.hide()
|
||||
self.selected = list(self.driver.selected)
|
||||
self.add_field_button.setHidden(True)
|
||||
|
||||
@@ -489,6 +506,7 @@ class PreviewPanel(QWidget):
|
||||
self.preview_img.show()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.hide()
|
||||
item: Entry = self.lib.get_entry(self.driver.selected[0][1])
|
||||
# If a new selection is made, update the thumbnail and filepath.
|
||||
if not self.selected or self.selected != self.driver.selected:
|
||||
@@ -520,6 +538,21 @@ class PreviewPanel(QWidget):
|
||||
# TODO: Do this all somewhere else, this is just here temporarily.
|
||||
ext: str = filepath.suffix.lower()
|
||||
try:
|
||||
if filepath.suffix.lower() in [".gif"]:
|
||||
movie = QMovie(str(filepath))
|
||||
image = Image.open(str(filepath))
|
||||
self.preview_gif.setMovie(movie)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
movie.start()
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.hide()
|
||||
self.preview_gif.show()
|
||||
|
||||
image = None
|
||||
if (
|
||||
(MediaType.IMAGE in MediaCategories.get_types(ext))
|
||||
@@ -546,25 +579,27 @@ class PreviewPanel(QWidget):
|
||||
):
|
||||
pass
|
||||
elif MediaType.VIDEO in MediaCategories.get_types(ext):
|
||||
video = cv2.VideoCapture(str(filepath))
|
||||
if video.get(cv2.CAP_PROP_FRAME_COUNT) <= 0:
|
||||
raise cv2.error("File is invalid or has 0 frames")
|
||||
video.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
if success:
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.play(
|
||||
filepath, QSize(image.width, image.height)
|
||||
if is_readable_video(filepath):
|
||||
video = cv2.VideoCapture(str(filepath), cv2.CAP_FFMPEG)
|
||||
video.set(
|
||||
cv2.CAP_PROP_POS_FRAMES,
|
||||
(video.get(cv2.CAP_PROP_FRAME_COUNT) // 2),
|
||||
)
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
success, frame = video.read()
|
||||
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
image = Image.fromarray(frame)
|
||||
if success:
|
||||
self.preview_img.hide()
|
||||
self.preview_vid.play(
|
||||
filepath, QSize(image.width, image.height)
|
||||
)
|
||||
)
|
||||
self.preview_vid.show()
|
||||
self.resizeEvent(
|
||||
QResizeEvent(
|
||||
QSize(image.width, image.height),
|
||||
QSize(image.width, image.height),
|
||||
)
|
||||
)
|
||||
self.preview_vid.show()
|
||||
|
||||
# Stats for specific file types are displayed here.
|
||||
if image and (
|
||||
@@ -607,7 +642,7 @@ class PreviewPanel(QWidget):
|
||||
)
|
||||
|
||||
except (FileNotFoundError, cv2.error) as e:
|
||||
self.dimensions_label.setText(f"{ext.upper()}")
|
||||
self.dimensions_label.setText(f"{ext.upper()[1:]}")
|
||||
logging.info(
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
@@ -622,6 +657,7 @@ class PreviewPanel(QWidget):
|
||||
f"[PreviewPanel][ERROR] Couldn't Render thumbnail for {filepath} (because of {e})"
|
||||
)
|
||||
|
||||
# TODO: Implement a clickable label to use for the GIF preview.
|
||||
if self.preview_img.is_connected:
|
||||
self.preview_img.clicked.disconnect()
|
||||
self.preview_img.clicked.connect(
|
||||
@@ -651,6 +687,7 @@ class PreviewPanel(QWidget):
|
||||
# Multiple Selected Items
|
||||
elif len(self.driver.selected) > 1:
|
||||
self.preview_img.show()
|
||||
self.preview_gif.hide()
|
||||
self.preview_vid.stop()
|
||||
self.preview_vid.hide()
|
||||
if self.selected != self.driver.selected:
|
||||
|
||||
@@ -9,6 +9,7 @@ import typing
|
||||
|
||||
from PySide6.QtCore import Signal, Qt
|
||||
from PySide6.QtWidgets import QPushButton
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
from src.core.constants import TAG_FAVORITE, TAG_ARCHIVED
|
||||
from src.core.library import Library, Tag
|
||||
@@ -49,6 +50,22 @@ class TagBoxWidget(FieldWidget):
|
||||
self.base_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(self.base_layout)
|
||||
|
||||
bg_color: str = (
|
||||
"#1E1E1E"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#EEEEEE"
|
||||
)
|
||||
fg_color: str = (
|
||||
"#FFFFFF"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#444444"
|
||||
)
|
||||
ol_color: str = (
|
||||
"#333333"
|
||||
if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
|
||||
else "#F5F5F5"
|
||||
)
|
||||
|
||||
self.add_button = QPushButton()
|
||||
self.add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.add_button.setMinimumSize(23, 23)
|
||||
@@ -56,10 +73,10 @@ class TagBoxWidget(FieldWidget):
|
||||
self.add_button.setText("+")
|
||||
self.add_button.setStyleSheet(
|
||||
f"QPushButton{{"
|
||||
f"background: #1e1e1e;"
|
||||
f"color: #FFFFFF;"
|
||||
f"background: {bg_color};"
|
||||
f"color: {fg_color};"
|
||||
f"font-weight: bold;"
|
||||
f"border-color: #333333;"
|
||||
f"border-color: {ol_color};"
|
||||
f"border-radius: 6px;"
|
||||
f"border-style:solid;"
|
||||
f"border-width:{math.ceil(1*self.devicePixelRatio())}px;"
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
|
||||
from PySide6 import QtCore
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtGui import QEnterEvent, QPainter, QColor, QPen, QPainterPath, QPaintEvent
|
||||
from PySide6.QtGui import (
|
||||
QEnterEvent,
|
||||
QPainter,
|
||||
QColor,
|
||||
QPen,
|
||||
QPainterPath,
|
||||
QPaintEvent,
|
||||
QPalette,
|
||||
)
|
||||
from PySide6.QtWidgets import QWidget
|
||||
from src.qt.helpers.qbutton_wrapper import QPushButtonWrapper
|
||||
|
||||
@@ -17,7 +25,31 @@ class ThumbButton(QPushButtonWrapper):
|
||||
self.hovered = False
|
||||
self.selected = False
|
||||
|
||||
# self.clicked.connect(lambda checked: self.set_selected(True))
|
||||
self.select_color: QColor = QPalette.color(
|
||||
self.palette(),
|
||||
QPalette.ColorGroup.Active,
|
||||
QPalette.ColorRole.Accent,
|
||||
)
|
||||
|
||||
self.select_color_faded: QColor = QColor(self.select_color)
|
||||
self.select_color_faded.setHsl(
|
||||
self.select_color_faded.hslHue(),
|
||||
self.select_color_faded.hslSaturation(),
|
||||
max(self.select_color_faded.lightness(), 127),
|
||||
127,
|
||||
)
|
||||
|
||||
self.hover_color: QColor = QPalette.color(
|
||||
self.palette(),
|
||||
QPalette.ColorGroup.Active,
|
||||
QPalette.ColorRole.Accent,
|
||||
)
|
||||
self.hover_color.setHsl(
|
||||
self.hover_color.hslHue(),
|
||||
self.hover_color.hslSaturation(),
|
||||
min(self.hover_color.lightness() + 80, 255),
|
||||
self.hover_color.alpha(),
|
||||
)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
@@ -25,7 +57,6 @@ class ThumbButton(QPushButtonWrapper):
|
||||
painter = QPainter()
|
||||
painter.begin(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
# painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
|
||||
path = QPainterPath()
|
||||
width = 3
|
||||
radius = 6
|
||||
@@ -40,27 +71,21 @@ class ThumbButton(QPushButtonWrapper):
|
||||
radius,
|
||||
)
|
||||
|
||||
# color = QColor('#bb4ff0') if self.selected else QColor('#55bbf6')
|
||||
# pen = QPen(color, width)
|
||||
# painter.setPen(pen)
|
||||
# # brush.setColor(fill)
|
||||
# painter.drawPath(path)
|
||||
|
||||
if self.selected:
|
||||
painter.setCompositionMode(
|
||||
QPainter.CompositionMode.CompositionMode_HardLight
|
||||
)
|
||||
color = QColor("#bb4ff0")
|
||||
color.setAlphaF(0.5)
|
||||
pen = QPen(color, width)
|
||||
pen = QPen(self.select_color_faded, width)
|
||||
painter.setPen(pen)
|
||||
painter.fillPath(path, color)
|
||||
painter.fillPath(path, self.select_color_faded)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.setCompositionMode(
|
||||
QPainter.CompositionMode.CompositionMode_Source
|
||||
)
|
||||
color = QColor("#bb4ff0") if not self.hovered else QColor("#55bbf6")
|
||||
color: QColor = (
|
||||
self.select_color if not self.hovered else self.hover_color
|
||||
)
|
||||
pen = QPen(color, width)
|
||||
painter.setPen(pen)
|
||||
painter.drawPath(path)
|
||||
@@ -68,10 +93,10 @@ class ThumbButton(QPushButtonWrapper):
|
||||
painter.setCompositionMode(
|
||||
QPainter.CompositionMode.CompositionMode_Source
|
||||
)
|
||||
color = QColor("#55bbf6")
|
||||
pen = QPen(color, width)
|
||||
pen = QPen(self.hover_color, width)
|
||||
painter.setPen(pen)
|
||||
painter.drawPath(path)
|
||||
|
||||
painter.end()
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
|
||||