From c3617c2d4ad0e79f8a928a6ce8e680cf95ce32ac Mon Sep 17 00:00:00 2001 From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:24:09 -0700 Subject: [PATCH] feat(ui): add changelog window --- docs/changelog.md | 6 +- pyproject.toml | 3 + scripts/tagstudio.spec | 1 + src/tagstudio/core/enums.py | 1 + .../qt/controllers/changelog_modal.py | 353 ++++++++++++++++++ src/tagstudio/qt/controllers/main_window.py | 4 + src/tagstudio/qt/controllers/modal.py | 13 + src/tagstudio/qt/controllers/modal_content.py | 10 +- src/tagstudio/qt/controllers/splash.py | 5 +- src/tagstudio/qt/mixed/about_modal.py | 25 +- src/tagstudio/qt/qt_driver.py | 37 ++ .../qt/views/changelog_modal_view.py | 61 +++ src/tagstudio/qt/views/modal_view.py | 4 +- src/tagstudio/qt/views/styles/palette.py | 6 + src/tagstudio/resources/translations/en.json | 5 + 15 files changed, 512 insertions(+), 22 deletions(-) create mode 100644 src/tagstudio/qt/controllers/changelog_modal.py create mode 100644 src/tagstudio/qt/views/changelog_modal_view.py diff --git a/docs/changelog.md b/docs/changelog.md index 9e2263f91..aacad79fd 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -59,8 +59,8 @@ This update includes some critical library bugfixes along with a handful QoL twe The tagging workflow has gotten a major overhaul! Clicking the "Add Tag" button or pressing Ctrl+T will now activate an inline tag search bar that you can also use to quickly create and apply new or existing tags! Creating new tags in an empty library is now as simple as clicking the "Add Tag" button, typing in the name of a tag you wish to create, and hitting Enter! The whole workflow was redone with both casual and power users in mind, and is entirely keyboard friendly. And fields have also gotten the same treatment! -![Empty File Entry](assets/tag_field_bars/add_buttons_normal.png){ width=45% } ![Empty File Entry](assets/tag_field_bars/tag_bar_empty.png){ width=45% } -![Empty File Entry](assets/tag_field_bars/tag_bar_search_match.png){ width=45% } ![Empty File Entry](assets/tag_field_bars/field_bar_search.png){ width=45% } +add_buttons_normal tag_bar_empty +tag_bar_search_match field_bar_search You can read the brief feature overview below or read the updated [documentation](https://docs.tagstud.io/usage/#tagging) to see the full changes and features: @@ -146,7 +146,7 @@ You can read the brief feature overview below or read the updated [documentation #### Customizable Fields -[Fields](fields.md) are now fully customizable! +[Fields](https://docs.tagstud.io/fields/) are now fully customizable! - The built-in list of fields (now referred to as "field templates") has been reduced to a list of handy essential templates that can be fully modified or deleted - "Text Line" and "Text Box" field types have been combined into a single "Text" type with a "Multiline" option that can be configured on templates or toggled dynamically on existing text fields diff --git a/pyproject.toml b/pyproject.toml index 428b60b9f..36142bcc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,9 @@ ruff = ["ruff==0.15.17"] [tool.hatch.build.targets.wheel] packages = ["src/tagstudio"] +[tool.hatch.build.targets.wheel.force-include] +"docs/changelog.md" = "tagstudio/resources/changelog/changelog.md" + [tool.pytest] qt_api = "pyside6" pythonpath = ["src"] diff --git a/scripts/tagstudio.spec b/scripts/tagstudio.spec index ed4cb92f2..70f60f40b 100755 --- a/scripts/tagstudio.spec +++ b/scripts/tagstudio.spec @@ -39,6 +39,7 @@ datafiles = [ (f"{project_root}/qt/*.json", "tagstudio/qt"), (f"{project_root}/qt/*.qrc", "tagstudio/qt"), (f"{project_root}/resources", "tagstudio/resources"), + ("../docs/changelog.md", "tagstudio/resources/changelog"), ] a = Analysis( diff --git a/src/tagstudio/core/enums.py b/src/tagstudio/core/enums.py index 7c2ded544..d2f6a7de3 100644 --- a/src/tagstudio/core/enums.py +++ b/src/tagstudio/core/enums.py @@ -11,6 +11,7 @@ class AppCacheItems(enum.StrEnum): LAST_LIBRARY = "last_library" LIBS_LIST = "libs_list" DISMISSED_UPDATE = "dismissed_update" + LAST_VERSION_OPENED = "last_version_opened" class ShowFilepathOption(enum.IntEnum): diff --git a/src/tagstudio/qt/controllers/changelog_modal.py b/src/tagstudio/qt/controllers/changelog_modal.py new file mode 100644 index 000000000..460492654 --- /dev/null +++ b/src/tagstudio/qt/controllers/changelog_modal.py @@ -0,0 +1,353 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import math +import re +from pathlib import Path +from typing import override + +import structlog +from PySide6.QtCore import QSize, Qt, QTimer, QUrl +from PySide6.QtGui import ( + QColor, + QDesktopServices, + QImage, + QPainter, + QPalette, + QPixmap, + QShowEvent, + QTextBlockFormat, + QTextCharFormat, + QTextCursor, + QTextDocument, + QTextFormat, +) +from PySide6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest + +from tagstudio.core.constants import DOCS_URL, GITHUB_REPO_URL +from tagstudio.core.utils.types import unwrap +from tagstudio.i18n.translations import Translations +from tagstudio.qt.controllers.modal_content import ModalContent +from tagstudio.qt.resource_manager import ResourceManager +from tagstudio.qt.views.changelog_modal_view import ChangelogModalView +from tagstudio.qt.views.modal_view import ModalView +from tagstudio.qt.views.styles.palette import ( + MUTED_PURPLE, + MUTED_PURPLE_OPAQUE, + ColorType, + UiColor, + get_ui_color, +) + +logger = structlog.get_logger(__name__) + +_CHANGELOG_NAME = "changelog.md" + +# GitHub Reference Patterns +_COMMIT_HASH = re.compile(r"\b[0-9a-f]{40}\b") +_ISSUE_REFERENCE = re.compile(r"(? None: + super().__init__() + self.setMinimumHeight(600) + self.setLayout(ChangelogModalView()) + + self._sections: list[str] = [] + self._loaded_count = 0 + self._is_loading = False + self._network = QNetworkAccessManager(self) + self._network.finished.connect(self._on_image_downloaded) + self._image_widths: dict[str, float] = {} # The original width of each tag + + path = self._find_changelog() + if path: + self._sections = self._split_changelog(path.read_text(encoding="utf-8")) + else: + logger.error("[ChangelogModal] Changelog file not found") + self.layout().text_browser.setPlainText(Translations["changelog.missing"]) + + scroll_bar = self.layout().text_browser.verticalScrollBar() + scroll_bar.valueChanged.connect(self._load_more_if_needed) + scroll_bar.rangeChanged.connect(self._load_more_if_needed) + + self.layout().view_more_button.clicked.connect( + lambda: QDesktopServices.openUrl(QUrl(f"{DOCS_URL}/changelog/")) + ) + # Use default browser to open links (Guard against internal markdown links) + self.layout().text_browser.anchorClicked.connect(QDesktopServices.openUrl) + self._load_more_if_needed() + + @staticmethod + def _find_changelog() -> Path | None: + """Find the CHANGELOG.MD file.""" + for path in ( + Path(__file__).parents[2] / "resources" / "changelog" / _CHANGELOG_NAME, # Build + Path(__file__).parents[4] / "docs" / _CHANGELOG_NAME, # Source / Dev Environment + ): + if path.is_file(): + return path + return None + + @staticmethod + def _split_changelog(markdown: str) -> list[str]: + """Split the changelog into one section per version.""" + # Turn GitHub references into hyperlinks + markdown = _ISSUE_REFERENCE.sub(rf"[#\1]({GITHUB_REPO_URL}/issues/\1)", markdown) + markdown = _USER_MENTION.sub(r"[@\1](https://github.com/\1)", markdown) + markdown = _COMMIT_HASH.sub( + lambda match: f"[{match[0][:7]}]({GITHUB_REPO_URL}/commit/{match[0]})", markdown + ) + # Strip everything before the first version (page title, frontmatter, etc.) + _header, *sections = markdown.split("\n## ") + # Drop "---" dividers, they'll be added in manually later + return [f"## {section.rstrip().removesuffix('---').rstrip()}" for section in sections] + + def _load_more_if_needed(self) -> None: + """Load more markdown sections, if needed.""" + text_browser = self.layout().text_browser + scroll_bar = text_browser.verticalScrollBar() + near_bottom = scroll_bar.value() >= scroll_bar.maximum() - scroll_bar.pageStep() + if self._is_loading or not near_bottom or self._loaded_count >= len(self._sections): + return + + self._is_loading = True + section = self._sections[self._loaded_count] + start = 0 + if self._loaded_count == 0: + text_browser.setMarkdown(section) + else: + cursor = QTextCursor(text_browser.document()) + cursor.movePosition(QTextCursor.MoveOperation.End) + start = cursor.position() + cursor.insertMarkdown(f"***\n\n{section}") # NOTE: Qt reads "---" as YAML frontmatter + + self._separate_images(start) + self._style_new_blocks(start) + self._loaded_count += 1 + self._is_loading = False + + # Recheck layout after inserting content in case things have moved + QTimer.singleShot(0, self._load_more_if_needed) + + def _separate_images(self, start: int) -> None: + """Separate images into their own blocks. + + Qt tries to merge tags into the markdown block, so this stops it from mangling them. + """ + document = self.layout().text_browser.document() + block = document.findBlock(start) + while block.isValid(): + fragments = block.begin() + while not fragments.atEnd(): + fragment = fragments.fragment() + if fragment.charFormat().isImageFormat(): + if fragment.position() > block.position(): + cursor = QTextCursor(document) + cursor.setPosition(fragment.position()) + cursor.insertBlock(QTextBlockFormat()) + break + fragments += 1 + block = block.next() + + def _style_new_blocks(self, start: int) -> None: + """Apply styling to the markdown document. + + Args: + start (int): The starting character position to style onwards from. + """ + document = self.layout().text_browser.document() + cursor = QTextCursor(document) + link_format = QTextCharFormat() + link_format.setForeground(QColor(get_ui_color(ColorType.PRIMARY, UiColor.BLUE))) + small_format = QTextCharFormat() + small_format.setForeground(QColor(MUTED_PURPLE_OPAQUE)) + small_format.setProperty(QTextFormat.Property.FontSizeAdjustment, 1) + italic_format = QTextCharFormat() + italic_format.setFontUnderline(False) # Read "_text_" as italic, not underlined + italic_format.setFontItalic(True) + + # Loop through each markdown block + block = document.findBlock(start) + while block.isValid(): + block_format = block.blockFormat() + heading_level = block_format.headingLevel() + + images: list[tuple[int, int]] = [] # (position, length) of each image + links: list[tuple[int, int]] = [] # (position, length) of each link + smalls: list[tuple[int, int]] = [] # Release dates ( tags) + underlines: list[tuple[int, int]] = [] + + # Loop through each markdown block "fragment" (individual styled text sections) + # and keep track of specific fragments that need special styling later + fragments = block.begin() + while not fragments.atEnd(): + fragment = fragments.fragment() + span = (fragment.position(), fragment.length()) + char_format = fragment.charFormat() + if char_format.isImageFormat(): + images.append(span) + elif char_format.isAnchor(): + links.append(span) + elif char_format.fontUnderline(): + underlines.append(span) + elif ( + heading_level == 2 + and char_format.property(QTextFormat.Property.FontSizeAdjustment) == -1 + ): + smalls.append(span) + fragments += 1 + + # Add alignment and spacing to each block + # NOTE: Neighboring margins aren't additive, only the larger margin is used + if heading_level == 2 or images: + block_format.setAlignment(Qt.AlignmentFlag.AlignHCenter) + if heading_level in (3, 4): + block_format.setTopMargin(_H3_SPACING if heading_level == 3 else _HEADING_SPACING) + block_format.setBottomMargin(_HEADING_SPACING) + if block.textList(): + is_list_end = not block.next().textList() + block_format.setTopMargin(_LIST_ITEM_SPACING) + block_format.setBottomMargin( + _LIST_END_SPACING if is_list_end else _LIST_ITEM_SPACING + ) + if block_format.hasProperty(QTextFormat.Property.BlockTrailingHorizontalRulerWidth): + block_format.setTopMargin(_DIVIDER_SPACING) + block_format.setBackground(QColor(MUTED_PURPLE)) + if images: + block_format.setTopMargin(_IMAGE_SPACING) + block_format.setBottomMargin(_IMAGE_SPACING) + # Spaces out rows of images that wrap within the same paragraph + block_format.setLineHeight( + _IMAGE_SPACING, QTextBlockFormat.LineHeightTypes.LineDistanceHeight.value + ) + if block_format != block.blockFormat(): + cursor.setPosition(block.position()) + cursor.setBlockFormat(block_format) + + # Force a specific blue color for links that's always readable on the background + for position, length in links: + cursor.setPosition(position) + cursor.setPosition(position + length, QTextCursor.MoveMode.KeepAnchor) + cursor.mergeCharFormat(link_format) + + # Apply styling to tags + for position, length in smalls: + cursor.setPosition(position) + cursor.setPosition(position + length, QTextCursor.MoveMode.KeepAnchor) + cursor.mergeCharFormat(small_format) + + # Use correct italics/underline formatting + for position, length in underlines: + cursor.setPosition(position) + cursor.setPosition(position + length, QTextCursor.MoveMode.KeepAnchor) + cursor.mergeCharFormat(italic_format) + + # Request image downloads, keep track of their widths, and clear the dimensions + for position, length in images: + cursor.setPosition(position) + cursor.setPosition(position + length, QTextCursor.MoveMode.KeepAnchor) + image_format = cursor.charFormat().toImageFormat() + url = image_format.name() + if url not in self._image_widths: + self._image_widths[url] = image_format.width() + self._network.get(QNetworkRequest(QUrl(url))) + image_format.clearProperty(QTextFormat.Property.ImageWidth) + image_format.clearProperty(QTextFormat.Property.ImageHeight) + cursor.setCharFormat(image_format) + block = block.next() + + def _on_image_downloaded(self, reply: QNetworkReply) -> None: + reply.deleteLater() + url = reply.request().url() + image = QImage.fromData(reply.readAll()) + if image.isNull(): + logger.warning( + "[ChangelogModal] Couldn't load image", url=url, error=reply.errorString() + ) + return + + width = min( + self._image_widths[url.toString()] or image.width(), + _IMAGE_MAX_WIDTH * _WINDOW_WIDTH, + _IMAGE_MAX_HEIGHT * image.width() / image.height(), + ) + pixel_ratio = self.devicePixelRatio() + image.setDevicePixelRatio(pixel_ratio) + image = image.scaledToWidth( + round(width * pixel_ratio), Qt.TransformationMode.SmoothTransformation + ) + + document = self.layout().text_browser.document() + document.addResource(QTextDocument.ResourceType.ImageResource.value, url, image) + document.markContentsDirty(0, document.characterCount()) + + @override + def parent_post_init(self) -> None: + window = self.window() + window.setFixedWidth(_WINDOW_WIDTH) + window.setMaximumHeight(_WINDOW_MAX_HEIGHT) + window.setStyleSheet("QLabel {color: white}") + + modal_view = window.layout() + assert isinstance(modal_view, ModalView) + close_button = unwrap(self.done_button) + close_button.setText(Translations["generic.close"]) + modal_view.button_layout.insertWidget( + modal_view.button_layout.indexOf(close_button), self.layout().view_more_button + ) + + # NOTE: A lot of the styling is similar to the "About" window, which eventually needs to be + # refactored to use the Modal class anyway, and can likely share a lot of this code + pixel_ratio = window.devicePixelRatio() + background = ResourceManager().about_bg + background.setDevicePixelRatio(pixel_ratio) + background = background.scaled( + QSize( + math.floor(_WINDOW_WIDTH * pixel_ratio), + math.floor(_WINDOW_MAX_HEIGHT * pixel_ratio * 0.8), + ), + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + + # NOTE: This stops the bg image from tiling and fills in the rest with a black background + canvas = QPixmap(background.width(), math.floor(_WINDOW_MAX_HEIGHT * pixel_ratio)) + canvas.setDevicePixelRatio(pixel_ratio) + canvas.fill(Qt.GlobalColor.black) + painter = QPainter(canvas) + painter.drawPixmap(0, 0, background) + painter.end() + + palette = window.palette() + palette.setBrush(QPalette.ColorRole.Window, canvas) + window.setPalette(palette) + + @override + def showEvent(self, event: QShowEvent) -> None: + self.layout().text_browser.verticalScrollBar().setValue(0) + return super().showEvent(event) + + @override + def layout(self) -> ChangelogModalView: + return super().layout() # pyright: ignore[reportReturnType] diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py index 98eeb4bad..66d825ff0 100644 --- a/src/tagstudio/qt/controllers/main_window.py +++ b/src/tagstudio/qt/controllers/main_window.py @@ -94,6 +94,7 @@ class MainMenuBar(QMenuBar): folders_to_tags_action: QAction help_menu: QMenu + changelog_action: QAction about_action: QAction def __init__(self, parent: QWidget | None = None): @@ -402,6 +403,9 @@ def setup_macros_menu(self): def setup_help_menu(self): self.help_menu = QMenu(Translations["menu.help"], self) + self.changelog_action = QAction(Translations["menu.help.changelog"], self) + self.help_menu.addAction(self.changelog_action) + self.about_action = QAction(Translations["menu.help.about"], self) self.help_menu.addAction(self.about_action) diff --git a/src/tagstudio/qt/controllers/modal.py b/src/tagstudio/qt/controllers/modal.py index f5417bba8..e4c44ce82 100644 --- a/src/tagstudio/qt/controllers/modal.py +++ b/src/tagstudio/qt/controllers/modal.py @@ -30,6 +30,7 @@ def __init__( window_title: str | None = None, is_savable: bool = False, inline_title: bool = True, + inline_title_level: int = 3, ): super().__init__() self.setWindowTitle(title if window_title is None else window_title) @@ -40,6 +41,7 @@ def __init__( title=title, is_savable=is_savable, inline_title=inline_title, + inline_title_level=inline_title_level, ) ) @@ -79,6 +81,17 @@ def closeEvent(self, event: QtGui.QCloseEvent) -> None: done_button.click() event.accept() + @override + def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: + if event.key() == Qt.Key.Key_Escape: + content_widget = self.layout().content_widget + if content_widget.cancel_button: + content_widget.cancel_button.click() + elif content_widget.done_button: + content_widget.done_button.click() + else: + super().keyPressEvent(event) + @override def layout(self) -> ModalView: """Return the typed layout for this widget.""" diff --git a/src/tagstudio/qt/controllers/modal_content.py b/src/tagstudio/qt/controllers/modal_content.py index 0a4348d91..005857161 100644 --- a/src/tagstudio/qt/controllers/modal_content.py +++ b/src/tagstudio/qt/controllers/modal_content.py @@ -5,7 +5,7 @@ from typing import Any, override import structlog -from PySide6 import QtCore, QtGui +from PySide6 import QtGui from PySide6.QtCore import Qt from PySide6.QtWidgets import QPushButton, QWidget @@ -33,12 +33,8 @@ def parent_post_init(self) -> None: @override def keyPressEvent(self, event: QtGui.QKeyEvent) -> None: - if event.key() == QtCore.Qt.Key.Key_Escape: - if self.cancel_button: - self.cancel_button.click() - elif self.done_button: - self.done_button.click() - elif event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: + # NOTE: Esc key handling is done by the Modal class + if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: if self.save_button: self.save_button.click() elif self.done_button: diff --git a/src/tagstudio/qt/controllers/splash.py b/src/tagstudio/qt/controllers/splash.py index 143db7116..2a39028f1 100644 --- a/src/tagstudio/qt/controllers/splash.py +++ b/src/tagstudio/qt/controllers/splash.py @@ -14,6 +14,7 @@ from tagstudio.i18n.translations import Translations from tagstudio.qt.app_settings import Splash from tagstudio.qt.resource_manager import ResourceManager +from tagstudio.qt.views.styles.palette import MUTED_PURPLE logger = structlog.get_logger(__name__) @@ -74,7 +75,7 @@ def get_pixmap(self) -> QPixmap: font = painter.font() font.setPointSize(math.floor(22 * point_size_scale)) painter.setFont(font) - pen = QPen(QColor("#809782ff")) + pen = QPen(QColor(MUTED_PURPLE)) painter.setPen(pen) painter.drawText( QRect(0, -25, 960, 540), @@ -95,7 +96,7 @@ def get_pixmap(self) -> QPixmap: font = painter.font() font.setPointSize(math.floor(22 * point_size_scale)) painter.setFont(font) - pen = QPen(QColor("#809782ff")) + pen = QPen(QColor(MUTED_PURPLE)) painter.setPen(pen) painter.drawText( QRect(40, 450, 960, 540), diff --git a/src/tagstudio/qt/mixed/about_modal.py b/src/tagstudio/qt/mixed/about_modal.py index 064c3b0d7..ad166f9fa 100644 --- a/src/tagstudio/qt/mixed/about_modal.py +++ b/src/tagstudio/qt/mixed/about_modal.py @@ -7,7 +7,7 @@ from PIL import ImageQt from PySide6.QtCore import QSize, Qt -from PySide6.QtGui import QPalette, QPixmap +from PySide6.QtGui import QPainter, QPalette, QPixmap from PySide6.QtWidgets import ( QFormLayout, QHBoxLayout, @@ -36,7 +36,7 @@ from tagstudio.qt.controllers.clickable_label import ClickableLabel from tagstudio.qt.resource_manager import ResourceManager from tagstudio.qt.utils.file_opener import open_file -from tagstudio.qt.views.styles.palette import ColorType, UiColor, get_ui_color +from tagstudio.qt.views.styles.palette import MUTED_PURPLE, ColorType, UiColor, get_ui_color from tagstudio.qt.views.styles.stylesheets import form_content_style, header @@ -64,7 +64,7 @@ def __init__(self, config_path: Path | str): self.setMinimumHeight(600) self.setMaximumHeight(900) self.root_layout = QVBoxLayout(self) - self.root_layout.setContentsMargins(0, 100, 0, 0) + self.root_layout.setContentsMargins(0, 60, 0, 0) self.root_layout.setSpacing(0) self.root_layout.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignCenter) @@ -89,13 +89,13 @@ def __init__(self, config_path: Path | str): self.logo_widget.setAlignment(Qt.AlignmentFlag.AlignCenter) # Version -------------------------------------------------------------- - self.version_label = QLabel(header(AboutModal.VERSION_STR, 3)) + self.version_label = QLabel(header(AboutModal.VERSION_STR, 2)) self.version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) # Copyright ------------------------------------------------------------ self.copyright_label = QLabel(COPYRIGHT) self.copyright_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.copyright_label.setStyleSheet("QLabel {color: #809782ff}") + self.copyright_label.setStyleSheet(f"QLabel {{color: {MUTED_PURPLE}}}") # Description ---------------------------------------------------------- self.desc_label = QLabel(Translations["about.description"]) @@ -231,7 +231,7 @@ def __init__(self, config_path: Path | str): f'{Translations["about.documentation"]} | ' f'Discord

' ) - self.links_label.setStyleSheet("QLabel {color: #809782ff}") + self.links_label.setStyleSheet(f"QLabel {{color: {MUTED_PURPLE}}}") self.links_label.setWordWrap(True) self.links_label.setOpenExternalLinks(True) self.links_label.setAlignment(Qt.AlignmentFlag.AlignCenter) @@ -262,13 +262,22 @@ def __init__(self, config_path: Path | str): self.bg_image = self.bg_image.scaled( QSize( math.floor(self.width() * pixel_ratio), - math.floor(self.maximumHeight() * pixel_ratio), + math.floor(self.maximumHeight() * pixel_ratio * 0.8), ), Qt.AspectRatioMode.IgnoreAspectRatio, Qt.TransformationMode.SmoothTransformation, ) + + # NOTE: This stops the bg image from tiling and fills in the rest with a black background. + canvas = QPixmap(self.bg_image.width(), math.floor(self.maximumHeight() * pixel_ratio)) + canvas.setDevicePixelRatio(pixel_ratio) + canvas.fill(Qt.GlobalColor.black) + painter = QPainter(canvas) + painter.drawPixmap(0, 0, self.bg_image) + painter.end() + palette = QPalette() - palette.setBrush(QPalette.ColorRole.Window, self.bg_image) + palette.setBrush(QPalette.ColorRole.Window, canvas) self.setPalette(palette) self.root_layout.addWidget(self.content_widget) diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py index 552bfe73e..5423d5628 100644 --- a/src/tagstudio/qt/qt_driver.py +++ b/src/tagstudio/qt/qt_driver.py @@ -23,6 +23,7 @@ from typing import TypeVar from warnings import catch_warnings +import semver import structlog from humanfriendly import format_size, format_timespan # pyright: ignore[reportUnknownVariableType] from PySide6.QtCore import QObject, QSettings, Qt, QThread, QThreadPool, QTimer, Signal @@ -61,6 +62,7 @@ from tagstudio.i18n.translations import Translations from tagstudio.qt.app_settings import DEFAULT_GLOBAL_SETTINGS_PATH, AppSettings, Theme from tagstudio.qt.cache_manager import CacheManager +from tagstudio.qt.controllers.changelog_modal import ChangelogModal from tagstudio.qt.controllers.field_template_search_panel import FieldTemplateSearchPanel from tagstudio.qt.controllers.fix_ignored_modal import FixIgnoredEntriesModal from tagstudio.qt.controllers.ignore_modal import IgnoreModal @@ -603,6 +605,7 @@ def create_about_modal(): self.about_modal.show() self.main_window.menu_bar.about_action.triggered.connect(create_about_modal) + self.main_window.menu_bar.changelog_action.triggered.connect(self.show_changelog) # endregion @@ -635,6 +638,7 @@ def on_visible_changed(entry_id: int | None): str(Path(__file__).parents[1] / "resources/qt/fonts/Oxanium-Bold.ttf") ) + show_changelog = self._record_program_version() self.init_library_window() self.migration_modal: JsonMigrationModal | None = None @@ -647,6 +651,8 @@ def on_visible_changed(entry_id: int | None): if path_result.success and path_result.library_path: self.open_library(path_result.library_path) + if show_changelog: + self.show_changelog() self.main_window.search_field.setFocus() self.check_for_update() @@ -1595,6 +1601,37 @@ def check_for_update(self): ) update_box.exec() + def _record_program_version(self) -> bool: + """Record the version of the program to the cache file. + + Returns: + bool: True if the current version is newer than the previously recorded one. + Will be False on first time runs of the program, and True if it's not the first run + but there's no existing record of the last version used. + """ + is_first_run = not self.cached_values.allKeys() # No cache keys means this is a first run + last_version_opened = self.cached_values.value(AppCacheItems.LAST_VERSION_OPENED) + self.cached_values.setValue(AppCacheItems.LAST_VERSION_OPENED, VERSION) + self.cached_values.sync() + + if is_first_run: + return False + if not last_version_opened: + return True + try: + return semver.Version.parse(str(last_version_opened)) < semver.Version.parse(VERSION) + except ValueError: + return str(last_version_opened) != VERSION + + def show_changelog(self): + if not hasattr(self, "changelog_modal"): + self.changelog_modal = Modal( + ChangelogModal(), + window_title=Translations["changelog.window_title"], + inline_title=False, + ) + self.changelog_modal.show() + def dismiss_update(self, version: str): """Dismiss an update notification for a specific new version of TagStudio.""" self.cached_values.setValue(AppCacheItems.DISMISSED_UPDATE, version) diff --git a/src/tagstudio/qt/views/changelog_modal_view.py b/src/tagstudio/qt/views/changelog_modal_view.py new file mode 100644 index 000000000..e0a53571e --- /dev/null +++ b/src/tagstudio/qt/views/changelog_modal_view.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: (c) TagStudio Contributors +# SPDX-License-Identifier: GPL-3.0-only + + +import math + +from PIL import ImageQt +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap +from PySide6.QtWidgets import QLabel, QPushButton, QTextBrowser, QVBoxLayout + +from tagstudio.core.enums import ThemePalette +from tagstudio.i18n.translations import Translations +from tagstudio.qt.resource_manager import ResourceManager +from tagstudio.qt.views.styles.stylesheets import header + +_TOP_SPACING = 60 +_LOGO_WIDTH = 384 + + +class ChangelogModalView(QVBoxLayout): + def __init__(self) -> None: + super().__init__() + self.setContentsMargins(6, 0, 6, 0) + + self.logo_label = QLabel() + logo = QPixmap.fromImage(ImageQt.ImageQt(ResourceManager().ts_logo_text_color)) + pixel_ratio = self.logo_label.devicePixelRatio() + logo.setDevicePixelRatio(pixel_ratio) + self.logo_label.setPixmap( + logo.scaledToWidth( + math.floor(_LOGO_WIDTH * pixel_ratio), Qt.TransformationMode.SmoothTransformation + ) + ) + self.logo_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.title_label = QLabel(header(Translations["changelog.title"], 2)) + self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.text_browser = QTextBrowser() + # NOTE: Guard against stray internal markdown links breaking the page. + # All links are opened by the controller class instead using the default browser. + self.text_browser.setOpenLinks(False) + self.text_browser.setStyleSheet( + "QTextBrowser {" + f"background: {ThemePalette.COLOR_BG_DARK.value};" + "border: none;" + "border-radius: 6px;" + "color: white;" + "}" + ) + self.text_browser.document().setDocumentMargin(6) + + self.addSpacing(_TOP_SPACING) + self.addWidget(self.logo_label) + self.addWidget(self.title_label) + self.addSpacing(6) + self.addWidget(self.text_browser) + + self.view_more_button = QPushButton(Translations["changelog.button.view_more"]) + self.view_more_button.setStyleSheet("QPushButton {padding: 3px 16px;}") diff --git a/src/tagstudio/qt/views/modal_view.py b/src/tagstudio/qt/views/modal_view.py index b082bc673..cf921189e 100644 --- a/src/tagstudio/qt/views/modal_view.py +++ b/src/tagstudio/qt/views/modal_view.py @@ -22,6 +22,7 @@ def __init__( title: str = "", is_savable: bool = False, inline_title: bool = True, + inline_title_level: int = 3, ): super().__init__() self.content_widget = content_widget @@ -54,11 +55,10 @@ def __init__( self.title_label = QLabel() self.title_label.setObjectName("fieldTitle") self.title_label.setWordWrap(True) - self.title_label.setText(header(title, 3)) + self.title_label.setText(header(title, inline_title_level)) self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.addWidget(self.title_label) self.addWidget(content_widget) self.setStretch(1, 2) self.addWidget(self.button_container) - content_widget.parent_post_init() diff --git a/src/tagstudio/qt/views/styles/palette.py b/src/tagstudio/qt/views/styles/palette.py index a3a567f5e..51aeef0bc 100644 --- a/src/tagstudio/qt/views/styles/palette.py +++ b/src/tagstudio/qt/views/styles/palette.py @@ -15,6 +15,12 @@ logger = structlog.get_logger(__name__) +# TODO: Completely rework the color palette system, including this file. + +# A translucent purple for de-emphasized text and lines, like the copyright and version dates +MUTED_PURPLE = "#809782ff" +MUTED_PURPLE_OPAQUE = "#9782ff" + class Palette(metaclass=Singleton): _palette: QPalette | None = None diff --git a/src/tagstudio/resources/translations/en.json b/src/tagstudio/resources/translations/en.json index 0d7afb6a4..251bc3aa1 100644 --- a/src/tagstudio/resources/translations/en.json +++ b/src/tagstudio/resources/translations/en.json @@ -14,6 +14,10 @@ "app.nightly": "Nightly", "app.pre_release": "Pre-Release", "app.title": "{base_title} - Library '{library_dir}'", + "changelog.button.view_more": "View More", + "changelog.missing": "The changelog could not be found.", + "changelog.title": "Changelog", + "changelog.window_title": "TagStudio Changelog", "color_manager.title": "Manage Tag Colors", "color.color_border": "Use Secondary Color for Border", "color.confirm_delete": "Are you sure you want to delete the color \"{color_name}\"?", @@ -285,6 +289,7 @@ "menu.file.save_library": "Save Library", "menu.help": "&Help", "menu.help.about": "About", + "menu.help.changelog": "View Changelog", "menu.macros": "&Macros", "menu.macros.folders_to_tags": "Folders to Tags", "menu.select": "Select",