diff --git a/REUSE.toml b/REUSE.toml index 93f80ffe..f6d1cc21 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -23,12 +23,12 @@ path = [ ".gitignore", "flake.lock", ] -SPDX-FileCopyrightText = "(c) TagStudio Contributors" +SPDX-FileCopyrightText = "TagStudio Contributors" SPDX-License-Identifier = "GPL-3.0-only" [[annotations]] path = ["src/tagstudio/resources/translations/*.json"] -SPDX-FileCopyrightText = "(c) TagStudio Contributors" +SPDX-FileCopyrightText = "TagStudio Contributors" SPDX-License-Identifier = "GPL-3.0-or-later" [[annotations]] @@ -41,7 +41,7 @@ path = [ "src/tagstudio/resources/qt/images/bxs-volume-full-solid.png", "src/tagstudio/resources/qt/images/file_icons/database.png", ] -SPDX-FileCopyrightText = "(c) 2026 Boxicons" +SPDX-FileCopyrightText = "2026 Boxicons" SPDX-License-Identifier = "MIT" [[annotations]] @@ -53,5 +53,16 @@ path = [ "src/tagstudio/resources/qt/images/hint_tag_added.png", "src/tagstudio/resources/qt/images/hint_tag_create.png", ] -SPDX-FileCopyrightText = "(c) github:google/material-design-icons Contributors" +SPDX-FileCopyrightText = "github:google/material-design-icons Contributors" SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = ["src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf"] +SPDX-FileCopyrightText = "2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium)" +SPDX-License-Identifier = "OFL-1.1" + + +[[annotations]] +path = ["src/tagstudio/resources/fonts/JetBrainsMono/**"] +SPDX-FileCopyrightText = "2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)" +SPDX-License-Identifier = "OFL-1.1" diff --git a/src/tagstudio/core/enums.py b/src/tagstudio/core/enums.py index ae8bf8bc..7c2ded54 100644 --- a/src/tagstudio/core/enums.py +++ b/src/tagstudio/core/enums.py @@ -31,7 +31,7 @@ class TagClickActionOption(enum.IntEnum): DEFAULT = OPEN_EDIT -class Theme(enum.StrEnum): +class ThemePalette(enum.StrEnum): COLOR_BG_DARK = "#65000000" COLOR_BG_LIGHT = "#22000000" COLOR_DARK_LABEL = "#DD000000" @@ -44,6 +44,13 @@ class Theme(enum.StrEnum): COLOR_FORBIDDEN_BG = "#65440D12" +class Theme(enum.IntEnum): + DARK = 0 + LIGHT = 1 + SYSTEM = 2 + DEFAULT = SYSTEM + + class OpenStatus(enum.IntEnum): NOT_FOUND = 0 SUCCESS = 1 diff --git a/src/tagstudio/core/library/refresh.py b/src/tagstudio/core/library/refresh.py index e0267b29..18848366 100644 --- a/src/tagstudio/core/library/refresh.py +++ b/src/tagstudio/core/library/refresh.py @@ -105,7 +105,14 @@ class RefreshTracker: shell=True, encoding="UTF-8", ) - compiled_ignore_path.unlink() + try: + compiled_ignore_path.unlink() + except Exception as e: + logger.error( + "[Refresh] Could not remove compiled ignore path", + path=compiled_ignore_path, + error=e, + ) if result.stderr: logger.error(result.stderr) diff --git a/src/tagstudio/core/media_types.py b/src/tagstudio/core/media_types.py index 324ca062..900f5655 100644 --- a/src/tagstudio/core/media_types.py +++ b/src/tagstudio/core/media_types.py @@ -37,8 +37,8 @@ class _Type: class MediaTypeGroup: def __init__(self, name_key: str, types: list[_Type]) -> None: - self.searchable: frozenset[str] - self.renderable: frozenset[str] + self.searchable: frozenset[str] # TODO: Make these dynamic + self.renderable: frozenset[str] # TODO: Make these dynamic self.name_key = name_key self.types = types # NOTE: Does self.types need to exist? @@ -78,254 +78,597 @@ class MediaTypeGroup: class MediaTypes: - SEARCH, RENDER = Context.SEARCH, Context.RENDER - - # Adobe ---------------------------------------------------------------------------------------- - adobe_photoshop = MediaTypeGroup( - "adobe.photoshop", - [ - _Type(".pdd", SEARCH), - _Type(".psb", SEARCH), - _Type(".psd", SEARCH), - ], - ) - adobe_illustrator = MediaTypeGroup( - "adobe.illustrator", - [ - _Type(".ai", SEARCH), - ], - ) - pdf = MediaTypeGroup( - "pdf", - [ - _Type(".pdf", [SEARCH, RENDER]), - ], - ) - - adobe = MediaTypeGroup("adobe", adobe_photoshop.types + adobe_illustrator.types + pdf.types) - - # Affinity ------------------------------------------------------------------------------------- - affinity_photo = MediaTypeGroup( - "affinity.photo", - [_Type(".afphoto", [SEARCH, RENDER])], - ) - affinity_designer = MediaTypeGroup( - "affinity.designer", - [_Type(".afdesign", [SEARCH, RENDER])], - ) - affinity_publisher = MediaTypeGroup( - "affinity.publisher", - [_Type([".afpublisher", ".afpub"], [SEARCH, RENDER])], - ) - - affinity = MediaTypeGroup( - "affinity", - affinity_photo.types - + affinity_designer.types - + affinity_publisher.types - + [_Type(".af", [SEARCH, RENDER])], - ) - - # MIDI ----------------------------------------------------------------------------------------- - midi = MediaTypeGroup("midi", [_Type([".mid", ".midi"], SEARCH)]) - - # Audio ---------------------------------------------------------------------------------------- - audio = MediaTypeGroup( - "audio", - [ - _Type(".aac", [RENDER, SEARCH]), - _Type( - [".aif", ".aiff", ".aifc"], - [RENDER, SEARCH], - ), - _Type(".caf", [RENDER, SEARCH]), - _Type(".flac", [RENDER, SEARCH]), - _Type(".m4a", [RENDER, SEARCH]), - _Type(".m4p", [RENDER, SEARCH]), - _Type(".mp3", [RENDER, SEARCH]), - _Type(".ogg", [RENDER, SEARCH]), - _Type(".wav", [RENDER, SEARCH]), - _Type(".wma", [RENDER, SEARCH]), - ] - + midi.types, - ) - - # RAW Images ----------------------------------------------------------------------------------- - raw_image = MediaTypeGroup( - "image.raw", - [ - _Type(".arw", [SEARCH, RENDER]), - _Type(".cr2", [SEARCH, RENDER]), - _Type(".cr3", [SEARCH, RENDER]), - _Type(".crw", [SEARCH, RENDER]), - _Type(".dng", [SEARCH, RENDER]), - _Type(".nef", [SEARCH, RENDER]), - _Type(".nrw", [SEARCH, RENDER]), - _Type(".orf", [SEARCH, RENDER]), - _Type(".r3d", [SEARCH, RENDER]), - _Type(".raf", [SEARCH, RENDER]), - _Type(".raw", [SEARCH, RENDER]), - _Type(".rw2", [SEARCH, RENDER]), - _Type(".srf", [SEARCH, RENDER]), - _Type(".srf2", [SEARCH, RENDER]), - ], - ) - - # Raster Images -------------------------------------------------------------------------------- - raster_image = MediaTypeGroup( - "image.raster", - [ - _Type(".apng", [SEARCH, RENDER]), - _Type(".avif", [SEARCH, RENDER]), - _Type(".bmp", [SEARCH, RENDER]), - _Type(".exr", [SEARCH, RENDER]), - _Type(".gif", [SEARCH, RENDER]), - _Type( - [ - ".jfif", - ".jpeg_large", - ".jpeg", - ".jpg_large", - ".jpg", - ], - [SEARCH, RENDER], - ), - _Type(".jxl", [SEARCH, RENDER]), - _Type(".png", [SEARCH, RENDER]), - _Type(".psb", RENDER), - _Type(".psd", RENDER), - _Type(".webp", [SEARCH, RENDER]), - _Type([".heic", ".heif"], [SEARCH, RENDER]), - _Type([".j2k", ".jp2", ".jpg2"], [SEARCH, RENDER]), - _Type([".tif", ".tiff"], [SEARCH, RENDER]), - ], - ) - - # Vector Images -------------------------------------------------------------------------------- - vector = MediaTypeGroup( - "image.vector", - [ - _Type(".ai", RENDER), - _Type(".eps", SEARCH), - _Type(".epsf", SEARCH), - _Type(".epsi", SEARCH), - _Type(".svg", [SEARCH, RENDER]), - _Type(".svgz", SEARCH), - ], - ) - - binary = MediaTypeGroup( - "binary", - [ - _Type(".dll", [RENDER, SEARCH]), - _Type(".dylib", [RENDER, SEARCH]), - _Type(".exe", [RENDER, SEARCH]), - _Type(".o", [RENDER, SEARCH]), - _Type(".pyc", [RENDER, SEARCH]), - _Type(".pyd", [RENDER, SEARCH]), - _Type(".pyo", [RENDER, SEARCH]), - ], - ) - - python = MediaTypeGroup( - "python", - [ - _Type(".ipynb", [RENDER, SEARCH]), - _Type(".py", [RENDER, SEARCH]), - _Type(".pyc", SEARCH), - _Type(".pyd", SEARCH), - _Type(".pyi", [RENDER, SEARCH]), - _Type(".pyo", SEARCH), - ], - ) - - javascript = MediaTypeGroup( - "javascript", - [ - _Type(".cjs", [SEARCH, RENDER]), - _Type(".js", [SEARCH, RENDER]), - _Type(".jsx", [SEARCH, RENDER]), - _Type(".mjs", [SEARCH, RENDER]), - ], - ) - - typescript = MediaTypeGroup( - "typescript", - [ - _Type(".cts", [SEARCH, RENDER]), - _Type(".mts", [SEARCH, RENDER]), - # _Type(".ts", [SEARCH, RENDER]), - _Type(".tsx", [SEARCH, RENDER]), - ], - ) - - # Shell Script --------------------------------------------------------------------------------- - shell = MediaTypeGroup( - "shell", - [ - _Type(".bat", [SEARCH, RENDER]), - _Type(".csh", [SEARCH, RENDER]), - _Type(".fish", [SEARCH, RENDER]), - _Type(".ps1", [SEARCH, RENDER]), - _Type(".sh", [SEARCH, RENDER]), - ], - ) - - # Markdown ------------------------------------------------------------------------------------- - markdown = MediaTypeGroup( - "markdown", - [ - _Type( - [ - ".markdown", - ".md", - ".mkd", - ".rmd", - ], - [SEARCH, RENDER], - ), - ], - ) - - # Plaintext ------------------------------------------------------------------------------------ - plaintext = MediaTypeGroup( - "plaintext", - [_Type([".txt", ".text"], [SEARCH, RENDER])] + markdown.types, - ) - - # Code ----------------------------------------------------------------------------------------- - code_types = MediaTypeGroup( - "type.code", - python.types + javascript.types + typescript.types + shell.types, - ) - - # Video ---------------------------------------------------------------------------------------- - video = MediaTypeGroup( - "video", - [ - _Type(".3gp", [SEARCH, RENDER]), - _Type(".avi", [SEARCH, RENDER]), - _Type(".flv", [SEARCH, RENDER]), - _Type(".gifv", [SEARCH, RENDER]), - _Type(".hevc", [SEARCH, RENDER]), - _Type(".m4p", [SEARCH, RENDER]), - _Type(".m4v", [SEARCH, RENDER]), - _Type(".mkv", [SEARCH, RENDER]), - _Type(".mov", [SEARCH, RENDER]), - _Type(".mp4", [SEARCH, RENDER]), - _Type(".webm", [SEARCH, RENDER]), - _Type(".wmv", [SEARCH, RENDER]), - ], - ) - - # ------------------ + # TODO: Implement filename equivalencies + # TODO: Implement in_all_groups or something, if needed + # TODO: Implement collision detection (e.g. ".ts") @staticmethod - def all_media_types(): - static_methods = [ - name for name, attr in MediaTypes.__dict__.items() if isinstance(attr, MediaTypeGroup) - ] - return static_methods + def register(group: MediaTypeGroup) -> None: + setattr(MediaTypes, group.name_key.replace(".", "_"), group) + + +SEARCH, RENDER = Context.SEARCH, Context.RENDER + +# Adobe ---------------------------------------------------------------------------------------- + +# TODO: Don't have these floating in the global space +_adobe_photoshop = MediaTypeGroup( + "adobe.photoshop", + [ + _Type(".pdd", SEARCH), + _Type(".psb", SEARCH), + _Type(".psd", SEARCH), + ], +) +MediaTypes.register(_adobe_photoshop) + +_adobe_illustrator = MediaTypeGroup( + "adobe.illustrator", + [ + _Type(".ai", SEARCH), + ], +) +MediaTypes.register(_adobe_illustrator) + +_pdf = MediaTypeGroup( + "pdf", + [ + _Type(".pdf", [SEARCH, RENDER]), + _Type(".ai", RENDER), + ], +) +MediaTypes.register(_pdf) + +adobe = MediaTypeGroup("adobe", _adobe_photoshop.types + _adobe_illustrator.types + _pdf.types) +MediaTypes.register(adobe) + +# Affinity ------------------------------------------------------------------------------------- +affinity_photo = MediaTypeGroup( + "affinity.photo", + [_Type(".afphoto", [SEARCH, RENDER])], +) +affinity_designer = MediaTypeGroup( + "affinity.designer", + [_Type(".afdesign", [SEARCH, RENDER])], +) +affinity_publisher = MediaTypeGroup( + "affinity.publisher", + [_Type([".afpublisher", ".afpub"], [SEARCH, RENDER])], +) + +affinity = MediaTypeGroup( + "affinity", + affinity_photo.types + + affinity_designer.types + + affinity_publisher.types + + [_Type(".af", [SEARCH, RENDER])], +) + +# Blender -------------------------------------------------------------------------------------- +_blender = MediaTypeGroup( + "blender", + [ + _Type(".blen_tc", [SEARCH, RENDER]), + _Type(".blend", [SEARCH, RENDER]), + # Numbered Blender auto-backup files (.blend1 - .blend32) + _Type([f".blend{i}" for i in range(1, 33)], [SEARCH, RENDER]), + ], +) +MediaTypes.register(_blender) + +# Clip Studio Paint ---------------------------------------------------------------------------- +clip_studio_paint = MediaTypeGroup( + "clip_studio_paint", + [ + _Type(".lip", [SEARCH, RENDER]), + _Type(".clip", [SEARCH, RENDER]), + _Type(".cmc", [SEARCH, RENDER]), + ], +) + +# Krita ---------------------------------------------------------------------------------------- +krita = MediaTypeGroup( + "krita", + [ + _Type(".kra", [SEARCH, RENDER]), + _Type(".krz", [SEARCH, RENDER]), + ], +) + +# MediBang Paint / FireAlpaca ------------------------------------------------------------------ +medibang_paint = MediaTypeGroup("medibang_paint", [_Type(".mdp", [SEARCH, RENDER])]) + +# Paint.NET ------------------------------------------------------------------------------------ +paint_dot_net = MediaTypeGroup("paint_dot_net", [_Type(".pdn", [SEARCH, RENDER])]) + +# Archives ------------------------------------------------------------------------------------- +archive = MediaTypeGroup( + "archive", + [ + _Type(".7z", [SEARCH, RENDER]), + _Type(".gz", SEARCH), + _Type(".rar", [SEARCH, RENDER]), + _Type(".s7z", [SEARCH, RENDER]), + _Type(".tar", [SEARCH, RENDER]), + _Type(".tgz", [SEARCH, RENDER]), + _Type(".zip", [SEARCH, RENDER]), + ], +) + +# eBooks ------------------------------------------------------------------------------------- +ebook = MediaTypeGroup( + "ebook", + [ + _Type(".azw", [SEARCH, RENDER]), + _Type(".azw3", [SEARCH, RENDER]), + _Type(".cb7", [SEARCH, RENDER]), + _Type(".cba", [SEARCH, RENDER]), + _Type(".cbr", [SEARCH, RENDER]), + _Type(".cbt", [SEARCH, RENDER]), + _Type(".cbz", [SEARCH, RENDER]), + _Type(".djvu", [SEARCH, RENDER]), + _Type(".epub", [SEARCH, RENDER]), + _Type(".fb2", [SEARCH, RENDER]), + _Type(".ibook", [SEARCH, RENDER]), + _Type(".inf", [SEARCH, RENDER]), + _Type(".kfx", [SEARCH, RENDER]), + _Type(".lit", [SEARCH, RENDER]), + _Type(".mobi", [SEARCH, RENDER]), + _Type(".pdb", [SEARCH, RENDER]), + _Type(".prc", [SEARCH, RENDER]), + _Type(".mscz", RENDER), + ], +) + +# Fonts -------------------------------------------------------------------------------------- +font = MediaTypeGroup( + "font", + [ + _Type(".fon", SEARCH), + _Type(".otf", [SEARCH, RENDER]), + _Type(".ttc", [SEARCH, RENDER]), + _Type(".ttf", [SEARCH, RENDER]), + _Type(".woff", [SEARCH, RENDER]), + _Type(".woff2", [SEARCH, RENDER]), + ], +) + +# Office & Documents --------------------------------------------------------------------------- +document = MediaTypeGroup( + "document", + [ + _Type(".doc", SEARCH), + _Type(".docm", SEARCH), + _Type(".docx", SEARCH), + _Type(".dot", SEARCH), + _Type(".dotm", SEARCH), + _Type(".dotx", SEARCH), + _Type(".odt", SEARCH), + _Type(".pages", SEARCH), + _Type(".pdf", SEARCH), + _Type(".pxd", SEARCH), + _Type(".rtf", SEARCH), + _Type(".tex", SEARCH), + _Type(".wpd", SEARCH), + _Type(".wps", SEARCH), + ], +) +open_document = MediaTypeGroup( + "open_document", + [ + _Type(".fodg", [SEARCH, RENDER]), + _Type(".fodp", [SEARCH, RENDER]), + _Type(".fods", [SEARCH, RENDER]), + _Type(".fodt", [SEARCH, RENDER]), + _Type(".mscz", SEARCH), + _Type(".odf", [SEARCH, RENDER]), + _Type(".odg", [SEARCH, RENDER]), + _Type(".odp", [SEARCH, RENDER]), + _Type(".ods", [SEARCH, RENDER]), + _Type(".odt", [SEARCH, RENDER]), + _Type(".ora", [SEARCH, RENDER]), + ], +) + +iwork = MediaTypeGroup( + "iwork", + [ + _Type(".key", [SEARCH, RENDER]), + _Type(".numbers", [SEARCH, RENDER]), + _Type(".pages", [SEARCH, RENDER]), + _Type(".pxd", RENDER), + ], +) + +presentation = MediaTypeGroup( + "presentation", + [ + _Type(".key", SEARCH), + _Type(".odp", SEARCH), + _Type(".ppt", SEARCH), + _Type(".pptx", [SEARCH, RENDER]), + ], +) + +spreadsheet = MediaTypeGroup( + "spreadsheet", + [ + _Type(".csv", SEARCH), + _Type(".numbers", SEARCH), + _Type(".ods", SEARCH), + _Type(".xls", SEARCH), + _Type(".xlsx", SEARCH), + ], +) + +# 3D ------------------------------------------------------------------------------------------- +model = MediaTypeGroup( + "model", + [ + _Type(".3ds", SEARCH), + _Type(".fbx", SEARCH), + _Type(".obj", SEARCH), + _Type(".stl", SEARCH), + ], +) +material = MediaTypeGroup( + "material", + [ + _Type(".mtl", SEARCH), + ], +) +shader = MediaTypeGroup( + "shader", + [ + _Type(".effect", SEARCH), + _Type(".frag", SEARCH), + _Type(".fsh", SEARCH), + _Type(".glsl", SEARCH), + _Type(".shader", SEARCH), + _Type(".vert", SEARCH), + _Type(".vsh", SEARCH), + ], +) + +# System & Misc ------------------------------------------------------------------------------ +database = MediaTypeGroup( + "database", + [ + _Type(".accdb", SEARCH), + _Type(".mdb", SEARCH), + _Type(".pdb", SEARCH), + _Type(".db", SEARCH), + _Type(".sqlite", SEARCH), + _Type(".sqlite3", SEARCH), + ], +) +disk_image = MediaTypeGroup( + "disk_image", + [ + _Type(".bios", SEARCH), + _Type(".dmg", SEARCH), + _Type(".fhdx", SEARCH), + _Type(".iso", SEARCH), + ], +) +installer = MediaTypeGroup( + "installer", + [ + _Type(".appx", SEARCH), + _Type(".msi", SEARCH), + _Type(".msix", SEARCH), + ], +) +package = MediaTypeGroup( + "package", + [ + _Type(".aab", SEARCH), + _Type(".akp", SEARCH), + _Type(".apk", SEARCH), + _Type(".apkm", SEARCH), + _Type(".apks", SEARCH), + _Type(".pkg", SEARCH), + _Type(".xapk", SEARCH), + ], +) +program = MediaTypeGroup( + "program", + [ + _Type(".app", SEARCH), + _Type(".bin", SEARCH), + _Type(".exe", SEARCH), + ], +) +shortcut = MediaTypeGroup( + "shortcut", + [ + _Type(".desktop", SEARCH), + _Type(".lnk", SEARCH), + _Type(".url", SEARCH), + ], +) + +# Valve Source Engine -------------------------------------------------------------------------- +source_engine = MediaTypeGroup( + "source_engine", + [_Type(".vtf", [SEARCH, RENDER])], +) + +# MIDI ----------------------------------------------------------------------------------------- +midi = MediaTypeGroup("midi", [_Type([".mid", ".midi"], SEARCH)]) + +# Audio ---------------------------------------------------------------------------------------- +audio = MediaTypeGroup( + "audio", + [ + _Type(".aac", [RENDER, SEARCH]), + _Type( + [".aif", ".aiff", ".aifc"], + [RENDER, SEARCH], + ), + _Type(".caf", [RENDER, SEARCH]), + _Type(".flac", [RENDER, SEARCH]), + _Type(".m4a", [RENDER, SEARCH]), + _Type(".m4p", [RENDER, SEARCH]), + _Type(".mp3", [RENDER, SEARCH]), + _Type(".ogg", [RENDER, SEARCH]), + _Type(".wav", [RENDER, SEARCH]), + _Type(".wma", [RENDER, SEARCH]), + ] + + midi.types, +) + +# RAW Images ----------------------------------------------------------------------------------- +raw_image = MediaTypeGroup( + "image.raw", + [ + _Type(".arw", [SEARCH, RENDER]), + _Type(".cr2", [SEARCH, RENDER]), + _Type(".cr3", [SEARCH, RENDER]), + _Type(".crw", [SEARCH, RENDER]), + _Type(".dng", [SEARCH, RENDER]), + _Type(".nef", [SEARCH, RENDER]), + _Type(".nrw", [SEARCH, RENDER]), + _Type(".orf", [SEARCH, RENDER]), + _Type(".r3d", [SEARCH, RENDER]), + _Type(".raf", [SEARCH, RENDER]), + _Type(".raw", [SEARCH, RENDER]), + _Type(".rw2", [SEARCH, RENDER]), + _Type(".srf", [SEARCH, RENDER]), + _Type(".srf2", [SEARCH, RENDER]), + ], +) + +# Raster Images -------------------------------------------------------------------------------- +raster_image = MediaTypeGroup( + "image.raster", + [ + _Type(".apng", [SEARCH, RENDER]), + _Type(".avif", [SEARCH, RENDER]), + _Type(".bmp", [SEARCH, RENDER]), + _Type(".exr", [SEARCH, RENDER]), + _Type(".gif", [SEARCH, RENDER]), + _Type( + [ + ".jfif", + ".jpeg_large", + ".jpeg", + ".jpg_large", + ".jpg", + ], + [SEARCH, RENDER], + ), + _Type(".jxl", [SEARCH, RENDER]), + _Type(".png", [SEARCH, RENDER]), + _Type(".psb", RENDER), + _Type(".psd", RENDER), + _Type(".webp", [SEARCH, RENDER]), + _Type([".heic", ".heif"], [SEARCH, RENDER]), + _Type([".j2k", ".jp2", ".jpg2"], [SEARCH, RENDER]), + _Type([".tif", ".tiff"], [SEARCH, RENDER]), + ], +) + +# Vector Images -------------------------------------------------------------------------------- +vector_image = MediaTypeGroup( + "image.vector", + [ + _Type(".eps", SEARCH), + _Type(".epsf", SEARCH), + _Type(".epsi", SEARCH), + _Type(".svg", [SEARCH, RENDER]), + _Type(".svgz", SEARCH), + ], +) + +# Animated Images ------------------------------------------------------------------------------ +animated_image = MediaTypeGroup( + "image.animated", + [ + _Type(".gif", [SEARCH, RENDER]), + _Type(".apng", [SEARCH, RENDER]), + _Type(".webp", [SEARCH, RENDER]), + _Type(".jxl", SEARCH), + ], +) + +# Binary --------------------------------------------------------------------------------------- +binary = MediaTypeGroup( + "binary", + [ + _Type(".dll", [RENDER, SEARCH]), + _Type(".dylib", [RENDER, SEARCH]), + _Type(".exe", [RENDER, SEARCH]), + _Type(".o", [RENDER, SEARCH]), + _Type(".pyc", [RENDER, SEARCH]), + _Type(".pyd", [RENDER, SEARCH]), + _Type(".pyo", [RENDER, SEARCH]), + ], +) + +# Python --------------------------------------------------------------------------------------- +python = MediaTypeGroup( + "python", + [ + _Type(".ipynb", [RENDER, SEARCH]), + _Type(".py", [RENDER, SEARCH]), + _Type(".pyc", SEARCH), + _Type(".pyd", SEARCH), + _Type(".pyi", [RENDER, SEARCH]), + _Type(".pyo", SEARCH), + ], +) + +# JavaScript ----------------------------------------------------------------------------------- +javascript = MediaTypeGroup( + "javascript", + [ + _Type(".cjs", [SEARCH, RENDER]), + _Type(".js", [SEARCH, RENDER]), + _Type(".jsx", [SEARCH, RENDER]), + _Type(".mjs", [SEARCH, RENDER]), + ], +) + +# TypeScript ----------------------------------------------------------------------------------- +typescript = MediaTypeGroup( + "typescript", + [ + _Type(".cts", [SEARCH, RENDER]), + _Type(".mts", [SEARCH, RENDER]), + # _Type(".ts", [SEARCH, RENDER]), + _Type(".tsx", [SEARCH, RENDER]), + ], +) + +# Shell Script --------------------------------------------------------------------------------- +shell = MediaTypeGroup( + "shell", + [ + _Type(".bat", [SEARCH, RENDER]), + _Type(".csh", [SEARCH, RENDER]), + _Type(".fish", [SEARCH, RENDER]), + _Type(".nu", [SEARCH, RENDER]), + _Type(".ps1", [SEARCH, RENDER]), + _Type(".sh", [SEARCH, RENDER]), + _Type("activate", [SEARCH, RENDER]), + ], +) + +# Markdown ------------------------------------------------------------------------------------- +markdown = MediaTypeGroup( + "markdown", + [ + _Type( + [ + ".markdown", + ".md", + ".mkd", + ".rmd", + ], + [SEARCH, RENDER], + ), + ], +) + +# Markup --------------------------------------------------------------------------------------- +markup = MediaTypeGroup( + "markup", + [ + _Type( + [ + ".yml", + ".yaml", + ], + [SEARCH, RENDER], + ), + _Type( + [ + ".json", + ".jsonc", + ".json5", + ".jsonl", + ], + [SEARCH, RENDER], + ), + _Type( + [ + ".xml", + ".xul", + ], + [SEARCH, RENDER], + ), + _Type( + [ + ".html", + ".htm", + ".xhtml", + ".shtml", + ".dhtml", + ], + [SEARCH, RENDER], + ), + _Type(".toml", [SEARCH, RENDER]), + ], +) + +# Code ----------------------------------------------------------------------------------------- +code = MediaTypeGroup( + "code", + [ + _Type(".lock", [SEARCH, RENDER]), + _Type(".log", [SEARCH, RENDER]), + ] + + python.types + + javascript.types + + typescript.types + + shell.types + + markdown.types + + markup.types, +) +MediaTypes.register(code) + +# Plaintext ------------------------------------------------------------------------------------ +plaintext = MediaTypeGroup( + "plaintext", + [ + _Type(".i3u", [SEARCH, RENDER]), + _Type(".lang", [SEARCH, RENDER]), + _Type("contributing", [SEARCH, RENDER]), + _Type("license", [SEARCH, RENDER]), + _Type("readme", [SEARCH, RENDER]), + _Type([".txt", ".text"], [SEARCH, RENDER]), + ], +) +MediaTypes.register(plaintext) + +# Video ---------------------------------------------------------------------------------------- +video = MediaTypeGroup( + "video", + [ + _Type(".3gp", [SEARCH, RENDER]), + _Type(".avi", [SEARCH, RENDER]), + _Type(".flv", [SEARCH, RENDER]), + _Type(".gifv", [SEARCH, RENDER]), + _Type(".hevc", [SEARCH, RENDER]), + _Type(".m4p", [SEARCH, RENDER]), + _Type(".m4v", [SEARCH, RENDER]), + _Type(".mkv", [SEARCH, RENDER]), + _Type(".mov", [SEARCH, RENDER]), + _Type(".mp4", [SEARCH, RENDER]), + _Type(".webm", [SEARCH, RENDER]), + _Type(".wmv", [SEARCH, RENDER]), + ], +) + +# ------------------ + +# @staticmethod +# def all_media_types(): +# static_methods = [ +# name for name, attr in MediaTypes.__dict__.items() if isinstance(attr, MediaTypeGroup) +# ] +# return static_methods FILETYPE_EQUIVALENTS = [ diff --git a/src/tagstudio/previews/base_preview.py b/src/tagstudio/previews/base_preview.py new file mode 100644 index 00000000..e5264378 --- /dev/null +++ b/src/tagstudio/previews/base_preview.py @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + +# pyright: standard + + +from pathlib import Path + +from PIL.Image import Image + +from tagstudio.core.enums import Theme + + +class BasePreview: + """A base preview renderer class.""" + + # The attribute name used for identifying the MediaType used with the preview renderer. + media_type_name: str + + def __init__(self) -> None: + pass + + @classmethod + def render( + cls, + filepath: Path, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + raise NotImplementedError diff --git a/src/tagstudio/previews/file_renderer.py b/src/tagstudio/previews/file_renderer.py index 18c22bfb..ecbc7ff9 100644 --- a/src/tagstudio/previews/file_renderer.py +++ b/src/tagstudio/previews/file_renderer.py @@ -14,6 +14,7 @@ import structlog from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFile, UnidentifiedImageError from PIL.Image import DecompressionBombError +from tagstudio.core.enums import Theme from tagstudio.core.exceptions import NoRendererError from tagstudio.core.library.alchemy.library import Library from tagstudio.core.library.ignore import Ignore @@ -25,6 +26,7 @@ from tagstudio.core.media_types import ( MediaTypes, ) from tagstudio.core.utils.types import unwrap +from tagstudio.previews.base_preview import BasePreview from tagstudio.previews.gradients import four_corner_gradient from tagstudio.previews.renderers.archive import ( apple_embedded_thumb, @@ -34,8 +36,9 @@ from tagstudio.previews.renderers.archive import ( powerpoint_thumb, ) from tagstudio.previews.renderers.audio import audio_album_thumb, audio_thumb, audio_waveform_thumb -from tagstudio.previews.renderers.blender import blender_thumb +from tagstudio.previews.renderers.blender import BlenderPreview, _blender_thumb from tagstudio.previews.renderers.clip_studio import clip_studio_thumb +from tagstudio.previews.renderers.code import CodePreview from tagstudio.previews.renderers.ebook import epub_thumb from tagstudio.previews.renderers.font import font_full_preview, font_small_thumb from tagstudio.previews.renderers.medibang_paint import medibang_paint_thumb @@ -47,7 +50,10 @@ from tagstudio.previews.renderers.raster_image import ( raw_image_thumb, ) from tagstudio.previews.renderers.source_engine import vtf_thumb -from tagstudio.previews.renderers.text import code_thumb, text_thumb +from tagstudio.previews.renderers.text import ( + TextPreview, + text_thumb, +) from tagstudio.previews.renderers.vector_image import vector_image_thumb from tagstudio.previews.renderers.video import video_thumb from tagstudio.qt.app_settings import ( @@ -55,7 +61,6 @@ from tagstudio.qt.app_settings import ( MAX_CACHED_THUMB_RES, MIN_CACHED_THUMB_RES, AppSettings, - Theme, ) from tagstudio.qt.cache_manager import CacheManager from tagstudio.qt.resource_manager import ResourceManager @@ -99,13 +104,13 @@ class FileRenderer: ext = url.suffix.lower() types: set[MediaTypeOld] = MediaCategories.get_types(ext, mime_fallback=True) - # Manual icon overrides. - if ext in {".gif", ".vtf"}: - return MediaTypeOld.IMAGE - elif ext in {".dll", ".pyc", ".o", ".dylib"}: - return MediaTypeOld.PROGRAM - elif ext in {".mscz"}: # noqa: SIM114 - return MediaTypeOld.TEXT + # # Manual icon overrides. + # if ext in {".gif", ".vtf"}: + # return MediaTypeOld.IMAGE + # elif ext in {".dll", ".pyc", ".o", ".dylib"}: + # return MediaTypeOld.PROGRAM + # elif ext in {".mscz"}: # noqa: SIM114 + # return MediaTypeOld.TEXT # Loop though the specific (non-IANA) categories and return the string # name of the first matching category found. @@ -766,17 +771,82 @@ class FileRenderer: # Ordered groups of file renderers. # A file extension is rendered with the first group it's found in. + + # TODO: Dynamically import these from the renderers/ directory at runtime, + # And allow user-created ones from an external directory. + previews: list[type[BasePreview]] = [ + # ArchivePreview, + # AudioPreview, + BlenderPreview, + # ClipStudioPaintPreview, + CodePreview, + # EbookPreview, + # FontPreview, + # IWorkPreview, + # KritaPreview, + # MediBangPaintPreview, + # PaintDotNetPreview, + # RasterImagePreview, + # RawImagePreview, + TextPreview, + # VectorImagePreview, + ] + + if filepath and filepath.is_file(): + try: + ext = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower() + for preview in previews: + media_type: MediaTypeGroup = getattr(MediaTypes, preview.media_type_name) + if media_type.contains(ext, Context.RENDER): + logger.info(f"{ext} in: {media_type.renderable}") + image = preview.render( + filepath=filepath, + theme=theme, + size=(scaled_size, scaled_size), + dpi_scale=dpi_scale, + ) + break + + if image: + image = self._resize_image(image, (scaled_size, scaled_size)) + if cache_filename and is_savable_type and image and cache: + cache.save_image(image, cache_filename, mode="RGBA") + except Exception as e: + logger.error("[FileRenderer] Couldn't render thumbnail", filepath=filepath, error=e) + image = None + + return image + + # -------------------------------- new old + render_groups: list[tuple[MediaTypeGroup, Callable[..., Image.Image | None]]] = [ (MediaTypes.raw_image, partial(raw_image_thumb, filepath)), - (MediaTypes.binary, partial(raster_image_thumb, filepath)), - (MediaTypes.python, partial(text_thumb, filepath)), - (MediaTypes.pdf, partial(pdf_thumb, filepath, scaled_size)), (MediaTypes.raster_image, partial(raster_image_thumb, filepath)), - (MediaTypes.vector, partial(vector_image_thumb, filepath, scaled_size)), - (MediaTypes.code_types, partial(code_thumb, filepath)), - (MediaTypes.plaintext, partial(text_thumb, filepath)), + (MediaTypes.vector_image, partial(vector_image_thumb, filepath, scaled_size)), + ( + getattr(MediaTypes, CodePreview.media_type_name), + partial(CodePreview.render, filepath), + ), + ( + getattr(MediaTypes, TextPreview.media_type_name), + partial(TextPreview.render, filepath), + ), (MediaTypes.audio, partial(audio_thumb, filepath, scaled_size, dpi_scale)), (MediaTypes.video, partial(video_thumb, filepath)), + ( + MediaTypes.font, + partial(font_small_thumb if is_thumb else font_full_preview, filepath, scaled_size), + ), + (MediaTypes.archive, partial(archive_thumb, filepath)), + (MediaTypes.pdf, partial(pdf_thumb, filepath, scaled_size)), + (MediaTypes.ebook, partial(epub_thumb, filepath)), + (MediaTypes.iwork, partial(apple_embedded_thumb, filepath)), + (MediaTypes.blender, partial(_blender_thumb, filepath)), + (MediaTypes.krita, partial(krita_thumb, filepath)), + (MediaTypes.clip_studio_paint, partial(clip_studio_thumb, filepath)), + (MediaTypes.paint_dot_net, partial(paint_dot_net_thumb, filepath)), + (MediaTypes.medibang_paint, partial(medibang_paint_thumb, filepath)), + (MediaTypes.binary, partial(raster_image_thumb, filepath)), ] if filepath and filepath.is_file(): try: @@ -791,6 +861,9 @@ class FileRenderer: # TODO: Differentiate between album art and waveform image = self._apply_overlay_color(image, UiColor.GREEN, theme) is_savable_type = False + elif image and ext in MediaTypes.font.renderable: + # TODO: Differentiate between ful preview and small preview + image = self._apply_overlay_color(image, UiColor.BLUE, theme) break @@ -821,6 +894,8 @@ class FileRenderer: return image + # ---------- old old + if filepath and filepath.is_file(): try: ext: str = filepath.suffix.lower() if filepath.suffix else filepath.stem.lower() @@ -854,7 +929,7 @@ class FileRenderer: ): image = raw_image_thumb(filepath) # Vector Images ---------------------------------------------------------------- - elif ext in MediaTypes.vector.renderable: + elif ext in MediaTypes.vector_image.renderable: image = vector_image_thumb(filepath, scaled_size) # EXR Images ------------------------------------------------------------------- elif ext in [".exr"]: @@ -912,7 +987,7 @@ class FileRenderer: elif MediaCategories.is_ext_in_category( ext, MediaCategories.BLENDER_TYPES, mime_fallback=True ): - image = blender_thumb(filepath) + image = _blender_thumb(filepath) # PDF ========================================================== elif MediaCategories.is_ext_in_category( ext, MediaCategories.PDF_TYPES, mime_fallback=True diff --git a/src/tagstudio/previews/renderers/archive.py b/src/tagstudio/previews/renderers/archive.py index 418d1e38..01623a40 100644 --- a/src/tagstudio/previews/renderers/archive.py +++ b/src/tagstudio/previews/renderers/archive.py @@ -60,7 +60,7 @@ class TarFile: self.tar.__exit__(*args) -def open_archive(filepath: Path, ext: str = "") -> Archive: +def open_archive(filepath: Path) -> Archive: """Open an archive with its corresponding archiver. Args: @@ -70,6 +70,7 @@ def open_archive(filepath: Path, ext: str = "") -> Archive: Returns: Archive: The opened archive. """ + ext = filepath.suffix.lower() archiver: type[Archive] = zipfile.ZipFile if ext in {".7z", ".cb7", ".s7z"}: archiver = SevenZipFile @@ -101,7 +102,6 @@ def first_image_in_archive(archive: Archive) -> Image.Image | None: def archive_thumb( filepath: Path, image_names: list[Path] | list[str] | None = None, - ext: str = "", ) -> Image.Image | None: """Extract an embedded preview image from an archive. @@ -114,7 +114,7 @@ def archive_thumb( Image: The first image found in the archive. """ try: - with open_archive(filepath, ext) as archive: + with open_archive(filepath) as archive: # If no list of image names to search for was provided, default to the first image. if not image_names: return first_image_in_archive(archive) diff --git a/src/tagstudio/previews/renderers/blender.py b/src/tagstudio/previews/renderers/blender.py index 8b0e4074..a043b269 100644 --- a/src/tagstudio/previews/renderers/blender.py +++ b/src/tagstudio/previews/renderers/blender.py @@ -3,32 +3,45 @@ from pathlib import Path +from typing import override import structlog -from PIL import Image -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication +from PIL.Image import Image, new +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview from tagstudio.previews.vendored.blender_thumbnailer import blend_thumb logger = structlog.get_logger(__name__) -def blender_thumb(filepath: Path) -> Image.Image | None: +class BlenderPreview(BasePreview): + media_type_name = "blender" + + @override + @classmethod + def render( + cls, + filepath: Path, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return _blender_thumb(filepath, theme) + + +def _blender_thumb(filepath: Path, theme: Theme) -> Image | None: """Get an emended thumbnail from a Blender file, if a thumbnail is present. Args: filepath (Path): The path of the file. + theme (Theme): The system color theme. """ - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - im: Image.Image | None = None + bg_color: str = "#1e1e1e" if theme == Theme.DARK else "#FFFFFF" + im: Image | None = None try: if (blend_image := blend_thumb(str(filepath))) is not None: - bg = Image.new("RGB", blend_image.size, color=bg_color) + bg = new("RGB", blend_image.size, color=bg_color) bg.paste(blend_image, mask=blend_image.getchannel(3)) im = bg else: diff --git a/src/tagstudio/previews/renderers/code.py b/src/tagstudio/previews/renderers/code.py new file mode 100644 index 00000000..c535424d --- /dev/null +++ b/src/tagstudio/previews/renderers/code.py @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: MIT + + +from pathlib import Path +from typing import override + +import structlog +from PIL.Image import ( + Image, +) +from pygments.style import Style +from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Literal, + Name, + Number, + Operator, + Other, + Punctuation, + String, + Text, + Whitespace, +) + +from tagstudio.core.enums import Theme +from tagstudio.previews.base_preview import BasePreview +from tagstudio.previews.renderers.text import text_thumb + +logger = structlog.get_logger(__name__) + + +class CodeStyle(Style): + background = "#111111" + foreground = "#f8f8f2" + selection = "#44475a" + comment = "#6272a4" + cyan = "#8be9fd" + green = "#50fa7b" + orange = "#ffb86c" + pink = "#ff79c6" + purple = "#bd93f9" + red = "#ff5555" + yellow = "#f1fa8c" + deletion = "#8b080b" + + background_color = background + highlight_color = selection + line_number_color = yellow + line_number_background_color = selection + line_number_special_color = green + line_number_special_background_color = comment + + styles = { + Whitespace: foreground, + Comment: comment, + Comment.Preproc: pink, + Generic: foreground, + Generic.Deleted: deletion, + Generic.Emph: "underline", + Generic.Heading: "bold", + Generic.Inserted: "bold", + Generic.Output: selection, + Generic.EmphStrong: "underline", + Generic.Subheading: "bold", + Error: foreground, + Keyword: pink, + Keyword.Constant: pink, + Keyword.Declaration: cyan + " italic", + Keyword.Type: cyan, + Literal: foreground, + Name: foreground, + Name.Attribute: green, + Name.Builtin: cyan + " italic", + Name.Builtin.Pseudo: foreground, + Name.Class: green, + Name.Function: green, + Name.Label: cyan + " italic", + Name.Tag: pink, + Name.Variable: cyan + " italic", + Number: orange, + Operator: pink, + Other: foreground, + Punctuation: foreground, + String: purple, + Text: foreground, + } + + +class CodePreview(BasePreview): + media_type_name = "code" + + @override + @classmethod + def render( + cls, + filepath: Path, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return text_thumb(filepath, size, CodeStyle) diff --git a/src/tagstudio/previews/renderers/ebook.py b/src/tagstudio/previews/renderers/ebook.py index b8fcf0be..b7d572c4 100644 --- a/src/tagstudio/previews/renderers/ebook.py +++ b/src/tagstudio/previews/renderers/ebook.py @@ -18,7 +18,7 @@ from tagstudio.previews.renderers.raster_image import image_from_bytes logger = structlog.get_logger(__name__) -def epub_thumb(filepath: Path, ext: str) -> Image.Image | None: +def epub_thumb(filepath: Path) -> Image.Image | None: """Extracts the cover specified by ComicInfo.xml or first image found in the ePub file. Args: @@ -31,7 +31,7 @@ def epub_thumb(filepath: Path, ext: str) -> Image.Image | None: """ im: Image.Image | None = None try: - with open_archive(filepath, ext) as archive: + with open_archive(filepath) as archive: if "ComicInfo.xml" in archive.namelist(): comic_info = ET.fromstring(archive.read("ComicInfo.xml")) im = _cover_from_comic_info(archive, comic_info, "FrontCover") diff --git a/src/tagstudio/previews/renderers/text.py b/src/tagstudio/previews/renderers/text.py index fdeddfb5..1460e63c 100644 --- a/src/tagstudio/previews/renderers/text.py +++ b/src/tagstudio/previews/renderers/text.py @@ -1,86 +1,172 @@ # SPDX-FileCopyrightText: (c) TagStudio Contributors -# SPDX-License-Identifier: GPL-3.0-only +# SPDX-License-Identifier: MIT +import io +import textwrap +from math import ceil from pathlib import Path +from typing import override -import cv2 import structlog -from PIL import Image, ImageDraw, UnidentifiedImageError -from PIL.Image import DecompressionBombError -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication +from PIL import ImageFont, UnidentifiedImageError +from PIL.Image import ( + DecompressionBombError, + Image, + Resampling, +) +from PIL.Image import ( + new as new_image, +) +from PIL.Image import ( + open as open_image, +) +from pygments import highlight +from pygments.formatters import ImageFormatter +from pygments.lexers import PythonLexer +from pygments.style import Style +from pygments.token import ( + Comment, + Error, + Generic, + Keyword, + Literal, + Name, + Number, + Operator, + Other, + Punctuation, + String, + Text, +) +from tagstudio.core.enums import Theme from tagstudio.core.utils.encoding import detect_char_encoding +from tagstudio.previews.base_preview import BasePreview logger = structlog.get_logger(__name__) -def text_thumb(filepath: Path) -> Image.Image | None: +class TextLightStyle(Style): + background = "#FFFFFF" + foreground = "#111111" + + background_color = background + styles = { + Generic: foreground, + Text: foreground, + Literal: foreground, + String: foreground, + } + + +class TextDarkStyle(Style): + background = "#1e1e1e" + foreground = "#FFFFFF" + + background_color = background + styles = { + Generic: foreground, + Text: foreground, + Literal: foreground, + String: foreground, + Comment: foreground, + Error: foreground, + Keyword: foreground, + Name: foreground, + Number: foreground, + Operator: foreground, + Other: foreground, + Punctuation: foreground, + } + + +# text_light_style = TextLightStyle() +# text_dark_style = TextDarkStyle() + + +class TextPreview(BasePreview): + media_type_name = "plaintext" + font = ImageFont.load_default(20) + + @override + @classmethod + def render( + cls, + filepath: Path, + theme: Theme, + size: tuple[int, int], + dpi_scale: float, + ) -> Image | None: + return text_thumb( + filepath=filepath, + size=size, + style=TextDarkStyle if theme == Theme.DARK else TextLightStyle, + ) + + +@staticmethod +def text_thumb( + filepath: Path, + size: tuple[int, int], + style: type[Style], +) -> Image | None: """Render a thumbnail for a plaintext file. Args: filepath (Path): The path of the file. + size (str): The final size for the image. + style (str): The pygments style class to use. """ - im: Image.Image | None = None - - bg_color: str = ( - "#1e1e1e" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#FFFFFF" - ) - fg_color: str = ( - "#FFFFFF" - if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else "#111111" - ) + im: Image | None = None try: - encoding = detect_char_encoding(filepath) + encoding: str = detect_char_encoding(filepath) or "UTF-8" with open(filepath, encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color=bg_color) - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=fg_color) - im = bg + text = text_file.read(1024) + + wrapped_text = "\n".join( + "\n".join(textwrap.wrap(line, width=40)) for line in text.splitlines() + ) + + # TODO: Get this path from the ResourceManager, when that can handle fonts. + font_path = str( + Path(__file__).parents[2] / "resources/fonts/JetBrainsMono/JetBrainsMono.ttf" + ) + # logger.info(font_path) + + image_bytes = highlight( + wrapped_text, + PythonLexer(), + ImageFormatter( + encoding=encoding, + font_name=font_path, + font_size=32, + line_numbers=False, + style=style, + image_pad=48, + ), + ) + im_text = open_image(io.BytesIO(image_bytes)) + + ratio_w = size[0] / im_text.width + im_text = Image.resize( + im_text, + (ceil(im_text.width * ratio_w), ceil(im_text.height * ratio_w)), + Resampling.BILINEAR, + ) + + bg = new_image("RGB", size, color=style.background_color) + Image.paste(bg, im_text, (0, 0)) + + return bg + except ( UnidentifiedImageError, - cv2.error, DecompressionBombError, UnicodeDecodeError, OSError, FileNotFoundError, ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) - return im - - -def code_thumb(filepath: Path) -> Image.Image | None: - """Render a thumbnail for a plaintext file. - - Args: - filepath (Path): The path of the file. - """ - im: Image.Image | None = None - - bg_color: str = "#000000" - fg_color: str = "#00FF00" - - try: - encoding = detect_char_encoding(filepath) - with open(filepath, encoding=encoding) as text_file: - text = text_file.read(256) - bg = Image.new("RGB", (256, 256), color=bg_color) - draw = ImageDraw.Draw(bg) - draw.text((16, 16), text, fill=fg_color) - im = bg - except ( - UnidentifiedImageError, - cv2.error, - DecompressionBombError, - UnicodeDecodeError, - OSError, - FileNotFoundError, - ) as e: - logger.error("Couldn't render thumbnail", filepath=filepath, error=type(e).__name__) + logger.error("Couldn't render thumbnail", filepath=filepath, error=e) return im diff --git a/src/tagstudio/qt/app_settings.py b/src/tagstudio/qt/app_settings.py index 450fa788..2ef96ea7 100644 --- a/src/tagstudio/qt/app_settings.py +++ b/src/tagstudio/qt/app_settings.py @@ -4,7 +4,7 @@ import platform from datetime import datetime -from enum import Enum, IntEnum, StrEnum +from enum import Enum, StrEnum from pathlib import Path from typing import override @@ -12,7 +12,7 @@ import structlog import toml from pydantic import BaseModel, Field -from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption +from tagstudio.core.enums import ShowFilepathOption, TagClickActionOption, Theme logger = structlog.get_logger(__name__) @@ -32,13 +32,6 @@ MAX_CACHED_THUMB_RES = 1024 # Pixels DEFAULT_CACHED_THUMB_RES = 256 # Pixels -class Theme(IntEnum): - DARK = 0 - LIGHT = 1 - SYSTEM = 2 - DEFAULT = SYSTEM - - class Splash(StrEnum): DEFAULT = "default" RANDOM = "random" diff --git a/src/tagstudio/qt/controllers/progress_bar.py b/src/tagstudio/qt/controllers/progress_bar.py index 59026b8b..3ace3ebe 100644 --- a/src/tagstudio/qt/controllers/progress_bar.py +++ b/src/tagstudio/qt/controllers/progress_bar.py @@ -64,8 +64,8 @@ class ProgressWidget(QWidget): self.show() - r = CustomRunnable(lambda: iterator.run()) - r.done.connect( + runnable = CustomRunnable(lambda: iterator.run()) + runnable.done.connect( lambda: (self.hide(), self.deleteLater(), [callback() for callback in done_callbacks]) ) - QThreadPool.globalInstance().start(r) + QThreadPool.globalInstance().start(runnable) diff --git a/src/tagstudio/qt/mixed/tag_color_manager.py b/src/tagstudio/qt/mixed/tag_color_manager.py index 5723616a..639859ba 100644 --- a/src/tagstudio/qt/mixed/tag_color_manager.py +++ b/src/tagstudio/qt/mixed/tag_color_manager.py @@ -22,7 +22,7 @@ from PySide6.QtWidgets import ( ) from tagstudio.core.constants import RESERVED_NAMESPACE_PREFIX -from tagstudio.core.enums import Theme +from tagstudio.core.enums import ThemePalette from tagstudio.core.utils.types import unwrap from tagstudio.i18n.translations import Translations from tagstudio.qt.controllers.modal import Modal @@ -56,9 +56,9 @@ class TagColorManager(QWidget): self.root_layout.setContentsMargins(6, 6, 6, 6) panel_bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) self.title_label = QLabel() diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py index c8ff15f2..69d1b085 100644 --- a/src/tagstudio/qt/views/styles/stylesheets.py +++ b/src/tagstudio/qt/views/styles/stylesheets.py @@ -5,7 +5,7 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QGuiApplication -from tagstudio.core.enums import Theme +from tagstudio.core.enums import ThemePalette from tagstudio.core.library.alchemy.enums import TagColorEnum from tagstudio.core.library.alchemy.models import Tag from tagstudio.qt.views.styles.palette import ( @@ -55,14 +55,14 @@ def button_style() -> str: """Style used for common QPushButtons.""" return f""" QPushButton{{ - background-color: {Theme.COLOR_BG.value}; + background-color: {ThemePalette.COLOR_BG.value}; border-radius: 6px; font-weight: 500; text-align: center; padding: 0px 12px; }} QPushButton::hover{{ - background-color: {Theme.COLOR_HOVER.value}; + background-color: {ThemePalette.COLOR_HOVER.value}; border-style: solid; border-width: 2px; border-color: {get_ui_color(ColorType.BORDER, UiColor.THEME_DARK)}; @@ -84,7 +84,7 @@ def button_style() -> str: padding: 0px 8px; }} QPushButton::disabled{{ - background-color: {Theme.COLOR_DISABLED_BG.value}; + background-color: {ThemePalette.COLOR_DISABLED_BG.value}; }} """ @@ -92,9 +92,9 @@ def button_style() -> str: def line_edit_style_main() -> str: """Style used for common QLineEdits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -118,7 +118,7 @@ def line_edit_style_main() -> str: padding: 0px 2px; }} QLineEdit::disabled{{ - background-color: {Theme.COLOR_DISABLED_BG.value}; + background-color: {ThemePalette.COLOR_DISABLED_BG.value}; }} """ @@ -259,10 +259,10 @@ def container_style() -> str: border-radius: 4px; }} QWidget#fieldContainer::hover{{ - background-color: {Theme.COLOR_HOVER.value}; + background-color: {ThemePalette.COLOR_HOVER.value}; }} QWidget#fieldContainer::pressed{{ - background-color: {Theme.COLOR_PRESSED.value}; + background-color: {ThemePalette.COLOR_PRESSED.value}; }} """ @@ -271,9 +271,9 @@ def form_content_style() -> str: return f""" QLabel{{ background-color: { - Theme.COLOR_BG.value + ThemePalette.COLOR_BG.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value }; border-radius: 3px; font-weight: 500; @@ -340,9 +340,9 @@ def list_button_style( def properties_style() -> str: """Style used for small labels such as file properties.""" label_bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_DARK_LABEL.value + else ThemePalette.COLOR_DARK_LABEL.value ) return f""" @@ -447,9 +447,9 @@ def title_line_edit_style() -> str: def inset_container_style(object_name: str = "") -> str: """Used for darkened inset areas.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -464,9 +464,9 @@ def inset_container_style(object_name: str = "") -> str: def autofill_scroll_top_style(object_name: str = "") -> str: """Used autofill lists positioned on top of line edits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -482,9 +482,9 @@ def autofill_scroll_top_style(object_name: str = "") -> str: def autofill_scroll_top_focus_style(object_name: str = "") -> str: """Used autofill lists positioned on top of line edits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -502,9 +502,9 @@ def autofill_scroll_top_focus_style(object_name: str = "") -> str: def autofill_line_edit_style() -> str: """Used for QLineEdits.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" @@ -525,9 +525,9 @@ def autofill_line_edit_style() -> str: def autofill_line_edit_top_style() -> str: """Used for QLineEdits when there's a top autofill section present.""" bg_color = ( - Theme.COLOR_BG_DARK.value + ThemePalette.COLOR_BG_DARK.value if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark - else Theme.COLOR_BG_LIGHT.value + else ThemePalette.COLOR_BG_LIGHT.value ) return f""" diff --git a/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf new file mode 100644 index 00000000..54148355 Binary files /dev/null and b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono-Italic.ttf differ diff --git a/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono.ttf b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono.ttf new file mode 100644 index 00000000..b60e77f5 Binary files /dev/null and b/src/tagstudio/resources/fonts/JetBrainsMono/JetBrainsMono.ttf differ diff --git a/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license b/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license deleted file mode 100644 index d56fe894..00000000 --- a/src/tagstudio/resources/qt/fonts/Oxanium-Bold.ttf.license +++ /dev/null @@ -1,2 +0,0 @@ -SPDX-FileCopyrightText: 2019 The Oxanium Project Authors (https://github.com/sevmeyer/oxanium) -SPDX-License-Identifier: OFL-1.1 diff --git a/tests/qt/test_theme_system.py b/tests/qt/test_theme_system.py index 9e12b8eb..f4cc55fc 100644 --- a/tests/qt/test_theme_system.py +++ b/tests/qt/test_theme_system.py @@ -9,7 +9,7 @@ from unittest.mock import Mock import pytest from PySide6.QtCore import Qt -from tagstudio.qt.app_settings import Theme +from tagstudio.core.enums import Theme @pytest.mark.parametrize(